chore(m2): 保存功能验收及后续清理前检查点

This commit is contained in:
Liubing\LB
2026-10-10 20:40:27 +08:00
parent d3c5394a55
commit 144e7f739f
110 changed files with 12672 additions and 7514 deletions

View File

@@ -1,5 +1,19 @@
# 项目通用开发规则 # 项目通用开发规则
- `CodexOutput/` 仅用于本地开发文档、评审及验证产物,不得纳入 Git 提交/推送、服务器上传、部署或发布包;不得使用 `git add -f` 强制加入。已被 Git 跟踪的内容仅取消跟踪,保留本地文件及现有修改。
- 2026-10-10用户要求开发后读取并执行:[PlayFab到KV-存量核查纠偏与开发后检查执行要求](CodexOutput/PlayFab到KV-存量核查纠偏与开发后检查执行要求-2026-10-10.md)。开发会话完成当前批次后必须读取本文,将存量核查纳入已有唯一TODO;后续每批开发及独立AI复核完成后执行其中第六部分的受影响范围自检,本轮核查纠偏完成后再做最终汇总检查,并在既有实施日志记录范围、证据和未闭合项。不能仅记录“已读”或用AI报告代替开发端检查;已有匹配hash的评审复用,不每批重复全工程扫描。发现超出已审边界的问题先补完整方案评审,不扩大业务重构或删除许可。检查通过且仍有已授权工作时继续,不停等用户催促。
- 2026-10-10用户明确要求持续执行:阻塞解除后,只要下一步工作仍在当前客户端侧且已有授权,就立即继续,不能因阶段汇报、更新文档、完成一个检查点/模块或本批审核通过而结束执行、等待用户再说“继续”。按唯一TODO串行推进;必要方案评审和独立AI代码复核由当前侧主动完成,复核通过后继续下一项,已有匹配hash的报告复用。遇到局部阻塞先保留证据并继续不依赖它的已授权工作;只有确实需要用户裁决/外部交付且无其他可推进项、用户明确暂停,或授权范围内工作全部完成时才停止,并说明具体原因。此规则覆盖旧的逐模块停下等人工review节奏,不免除编码前完整方案评审,不扩大1:1迁移、服务器只读、保留工作树及暂不打包边界。
- 2026-10-09最新用户授权:因人工难以阅读整批差异,使用独立AI代码review承接本次模块review,参考裁决后继续后续。模块仍串行,编码前完整方案评审不省略;每模块代码与必要验证完成后由AI复核,发现问题先修本模块,通过后继续。已有匹配当前hash的独立AI报告优先复用,不重复送审;未定业务合同、行为差异或必须由用户裁决的问题仍集中说明。本授权不构成服务器修改、业务优化、整文件回滚或打包许可,也不冒充正式全链验收。
- 2026-10-09用户再次确认:PlayerKV精简仍受全流程1:1平移规则约束,不是架构升级或业务重构授权。只核减本次迁移新增且已被等价替换的重复实现;先列原版→当前→目标的方法、数据及顺序对照,再评审实施。已知行为偏差必须列入唯一模块TODO,未验证不得宣称无漂移;不因服务器暂未交付擅自改变业务、永久冻结功能或扩大改造范围。
- 当前执行边界(2026-10-08本轮用户指令):保留旧 PlayFabUserService、AggUserService 原实现,新增 KV 服务承接迁移功能;保留现有改动,不整文件回滚。下条此前记录的“最终删除旧类”不构成本轮删除许可,也不作为当前方案前提。先区分既有共用接口、原类行为差异和KV专用实现;冲突范围完成具体方案评审后再改,不继续向旧PF添加KV职责。核对记录见 CodexOutput/upserver/B3BatchMigration/M2/M2-保留旧PF决议与当前评审工作树核对-2026-10-08.md。
- PlayFabUserService 是迁移完成后要移除的旧实现,不是 KV 的宿主或最终双后端架构。迁移期间保留旧类仅用于原行为对照及尚未迁出的有效消费者;不得再为 KV 加接口、partial、验证构造器或加载分支。KVUserService / AggKVUserService / DevKVUserService 独立承接原能力,相关消费者与有效验证迁完后删除旧 PF 实现及其迁移残留。保持原业务行为不等于永久保留旧类;不整文件回滚,不覆盖用户编辑,不提前删除仍在使用的功能。
- KV模式的迁移目标是由KV管理器及其接线承接原PlayFab相关能力,不只迁个人存档;等级榜、公开资料、好友和匹配是原PlayFab调用的既有功能,不得当作新增产品需求、默认延期或直接转交服务端。先核客户端已有KV能力及等价调用映射;只有明确证实缺少必要能力时,才把具体接口证据集中写入沟通文档。不得反复要求用户确认“是否存KV”、新ID或原邮件流程。已审同合同替换连续实施,确有新合同或架构差异时一次合并评审,避免逐功能送审。 - KV模式的迁移目标是由KV管理器及其接线承接原PlayFab相关能力,不只迁个人存档;等级榜、公开资料、好友和匹配是原PlayFab调用的既有功能,不得当作新增产品需求、默认延期或直接转交服务端。先核客户端已有KV能力及等价调用映射;只有明确证实缺少必要能力时,才把具体接口证据集中写入沟通文档。不得反复要求用户确认“是否存KV”、新ID或原邮件流程。已审同合同替换连续实施,确有新合同或架构差异时一次合并评审,避免逐功能送审。
- 整个迁移流程、所有阶段与批次均只做平移,不限于 M2。全部迁移完成前,仅修改完成迁移所必需的存储、身份和接口接线,保留原业务规则、调用顺序、同步/异步方式、返回值、异常处理和界面流程。不得夹带额外业务修正、优化或代码重构;不得以“更安全”“更合理”或“顺手修复”为由改变原逻辑。 - 整个迁移流程、所有阶段与批次均只做平移,不限于 M2。全部迁移完成前,仅修改完成迁移所必需的存储、身份和接口接线,保留原业务规则、调用顺序、同步/异步方式、返回值、异常处理和界面流程。不得夹带额外业务修正、优化或代码重构;不得以“更安全”“更合理”或“顺手修复”为由改变原逻辑。
- 发现原有业务缺陷、代码结构问题或可优化点,统一记录到迁移完成后的待办,不在任何迁移阶段穿插处理。必须等整个迁移流程全部完成后,再由用户另行授权修正;单个阶段完成不构成提前修正的许可。此前方案或评审包含此类扩展时,以此规则及用户最新要求为准。 - 发现原有业务缺陷、代码结构问题或可优化点,统一记录到迁移完成后的待办,不在任何迁移阶段穿插处理。必须等整个迁移流程全部完成后,再由用户另行授权修正;单个阶段完成不构成提前修正的许可。此前方案或评审包含此类扩展时,以此规则及用户最新要求为准。
@@ -7,3 +21,41 @@
- 在满足已确认的功能、正确性和必要安全边界的前提下,优先选择最简单、最直观、最容易维护的实现。抽象应服务于实际调用者;不要为假设中的扩展增加接口、适配器、管理器、状态机或重复保护层。 - 在满足已确认的功能、正确性和必要安全边界的前提下,优先选择最简单、最直观、最容易维护的实现。抽象应服务于实际调用者;不要为假设中的扩展增加接口、适配器、管理器、状态机或重复保护层。
- 修改代码前完成相关方案的完整评审,记录裁决和开发边界。评审新增抽象时,说明它解决的具体问题、实际调用点,以及为何复用或小幅修改现有实现不足;无法说明必要性的层次不要引入。 - 修改代码前完成相关方案的完整评审,记录裁决和开发边界。评审新增抽象时,说明它解决的具体问题、实际调用点,以及为何复用或小幅修改现有实现不足;无法说明必要性的层次不要引入。
- 同一事实保持单一来源,同一业务流程避免多重开关和重复状态。迁移中的临时路径须登记,最终通关前恢复或清理;最终代码删除闲置实现,但保留仍在使用的原有功能。 - 同一事实保持单一来源,同一业务流程避免多重开关和重复状态。迁移中的临时路径须登记,最终通关前恢复或清理;最终代码删除闲置实现,但保留仍在使用的原有功能。
- 原设计是迁移基准,尽量做到 1:1 平移:优先复用原方法、业务调用链和初始化入口,仅替换必要的数据来源、身份值和存储接口。不得以迁移为由过度设计、升级架构,或另建与原流程并行的解码、模块装配、初始化和状态管理体系。只有经源码证实无法复用的具体差异才进入集中评审,说明最小改动及行为影响;不得先增加层次再要求原业务适配。已经引入的重复实现须列入方案修订,完整评审并验证等价后再替换和清理,不能直接删除仍在使用的代码。
- 2026-10-08历史节奏(已由2026-10-09独立AI模块review授权、2026-10-10持续执行规则替代):原要求每模块完成后停下等人工review。当前仍按模块串行、先完整评审再编码,提供原版对照/代码入口/差异/验证,模块问题先修本模块;独立AI复核通过后继续,不再机械等待人工回复。
## C# 代码格式
- `[Inject]` 必须单独占一行,被标注的字段、属性或方法声明从下一行开始;不得写成 `[Inject] public ...`。新增或修改注入声明时遵守此规则,保留已有的换行格式;不因此批量重排无关代码。
- 字段必须显式声明访问修饰符,类内部使用的字段优先写 `private`,不要依赖省略修饰符时的默认可见性。例如写 `private readonly ...`、`private static ...`、`private ...`。此规则指访问级别,不要求给变量名增加前缀或改名。
- 按实际消费者选择最小必要访问级别:跨类调用、依赖注入、接口或继承确需 `internal`、`protected`、`public` 时保留对应权限,不得为了统一为 `private` 而破坏现有调用。构造器也须核对创建位置;局部变量、参数及不允许访问修饰符的显式接口实现不套用字段规则。不批量调整无关代码。
- `if`、`else if`、`else` 分支必须使用大括号,即使只有一条语句也不省略;条件、分支语句不能挤在同一行,不写 `if (...) return ...;` 或 `{ statement; }`。
- 使用大括号的代码块采用换行格式:左、右大括号各自独立成行,块内语句逐行书写并缩进。方法、构造器、属性访问器及控制流块均保持清晰展开,不用一行多个语句压缩代码;不改变条件、执行顺序或原业务行为。
```csharp
[Inject]
public ICustomServerMgr customServerMgr
{
get;
set;
}
[Inject]
public IPlayerSaveStore SaveStore
{
get;
set;
}
private readonly PlayerKVRuntimeSelection selection;
private PlayerKVGameSessionHandle handle;
```
条件分支示例:
```csharp
if (!IsAuthorized)
{
return ELoginStep.Api;
}
```

File diff suppressed because one or more lines are too long

View File

@@ -9,14 +9,22 @@ public static class KVGameLoadValidation
// Fixed assembly/type/method and explicit case allow-list; no arbitrary reflection arguments. // Fixed assembly/type/method and explicit case allow-list; no arbitrary reflection arguments.
public static async Task<string> RunAsync(string caseId) public static async Task<string> RunAsync(string caseId)
{ {
if (caseId != "M08-M23" && caseId != "M21-M22-M24" && caseId != "M09-M10-M12" && if (caseId != "M08-M23" && caseId != "M21-M22-M24" && caseId != "M09-M10-M12" && caseId != "M14" && caseId != "M07" && caseId != "M20-M21-M22-M24-M28-M29" && caseId != "M16-M17" && caseId != "M06-M13" && caseId != "M15-M26-M27" && caseId != "M30" && caseId != "M19" && caseId != "M11" && caseId != "M25" && caseId != "M01-M02-M21-M22-M24" && caseId != "M04-M21-M22-M24" && caseId != "M05-M21-M22-M24" && caseId != "M03" && caseId != "M18" && caseId != "M31" && caseId != "B2-20" && caseId != "M01-Immediate" && caseId != "Module02-OriginalLoad" && caseId != "M03B-LoginRetry" && caseId != "M03B-LoginRetry-Cleanup" && caseId != "M03C-CustomId" && caseId != "M03D-Destroy")
caseId != "M14" && caseId != "M07" && caseId != "M20-M21-M22-M24-M28-M29" && caseId != "M16-M17" && {
caseId != "M06-M13" && caseId != "M15-M26-M27" && caseId != "M30" && caseId != "M19" && caseId != "M11" && caseId != "M25" && if (caseId != "P6A-InitializationLastLogin" && caseId != "P6B-OrdinarySave" && caseId != "P6B-OrdinarySave-Cleanup" && caseId != "P8A-Leaderboard" && caseId != "P8B-Friends" && caseId != "P8C-TitleNews" && caseId != "P8C-TitleNews-Cleanup" && caseId != "P8D-Matchmaking" && caseId != "P12A-OrdinaryCadence" && caseId != "P12B-FormalReadAdapters" && caseId != "P12C-StartupSubscriptions" && caseId != "P14-FormalRealtime" && caseId != "P15-FormalMatchmaking" && caseId != "P15-FormalMatchState")
caseId != "M01-M02-M21-M22-M24" && caseId != "M04-M21-M22-M24" && caseId != "M05-M21-M22-M24" && caseId != "M03" && caseId != "M18" && caseId != "M31" && caseId != "B2-20") throw new ArgumentException("Unknown fixed validation case."); {
throw new ArgumentException("Unknown fixed validation case.");
}
}
var assembly = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "HotUpdate"); var assembly = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "HotUpdate");
var type = assembly.GetType("game.PlayerDataStorage.PlayerKVEditorValidationHost", true); var type = assembly.GetType("game.PlayerDataStorage.PlayerKVEditorValidationHost", true);
var method = type.GetMethod("RunAsync", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null); var method = type.GetMethod("RunAsync", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null);
if (method == null) throw new InvalidOperationException("Validation entry unavailable."); if (method == null)
{
throw new InvalidOperationException("Validation entry unavailable.");
}
return await (Task<string>)method.Invoke(null, new object[] { caseId }); return await (Task<string>)method.Invoke(null, new object[] { caseId });
} }
} }

View File

@@ -1,11 +1,9 @@
#if UNITY_EDITOR #if UNITY_EDITOR
using System; using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Security.Cryptography;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
@@ -19,11 +17,36 @@ public static class KVGameplayLoopSetup
public static async System.Threading.Tasks.Task<string> RunValidationAsync(string caseId) public static async System.Threading.Tasks.Task<string> RunValidationAsync(string caseId)
{ {
if(caseId!="equipment-chain" && caseId!="fishing-consume" && caseId!="fishing-reward") throw new ArgumentException("Unknown fixed validation case."); string methodName;
switch (caseId)
{
case "equipment-chain":
methodName = "VerifyEquipmentForEditorAsync";
break;
case "fishing-consume":
methodName = "VerifyFishingForEditorAsync";
break;
case "fishing-reward":
methodName = "VerifyFishingRewardForEditorAsync";
break;
case "fishing-original-reward":
methodName = "VerifyOriginalFishingRewardForEditorAsync";
break;
case "fishing-original-consume":
methodName = "VerifyOriginalFishingForEditorAsync";
break;
default:
throw new ArgumentException("Unknown fixed validation case.");
}
if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating) if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating)
{
throw new InvalidOperationException("Idle editor required."); throw new InvalidOperationException("Idle editor required.");
var method=Find("PlayerKVGameplayLoop").GetMethod(caseId=="equipment-chain"?"VerifyEquipmentForEditorAsync":caseId=="fishing-consume"?"VerifyFishingForEditorAsync":"VerifyFishingRewardForEditorAsync",BindingFlags.Static|BindingFlags.NonPublic); }
if(method==null) throw new InvalidOperationException("Fixed equipment validation entry unavailable."); var method = Find("PlayerKVGameplayLoop").GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic);
if (method == null)
{
throw new InvalidOperationException("Fixed equipment validation entry unavailable.");
}
return await (System.Threading.Tasks.Task<string>)method.Invoke(null,null); return await (System.Threading.Tasks.Task<string>)method.Invoke(null,null);
} }
@@ -37,17 +60,6 @@ public static class KVGameplayLoopSetup
processMarkers=Directory.Exists(history)?Directory.GetFiles(history,"*.claimed").Select(Path.GetFileName).OrderBy(x=>x).ToArray():Array.Empty<string>()}); processMarkers=Directory.Exists(history)?Directory.GetFiles(history,"*.claimed").Select(Path.GetFileName).OrderBy(x=>x).ToArray():Array.Empty<string>()});
} }
public static string ValidatePermit(string path)
{
var type=Find("PlayerKVLoopPermit");
try
{
var permit=type.GetMethod("Read",BindingFlags.Static|BindingFlags.NonPublic).Invoke(null,new object[]{Path.GetFullPath(path)});
return JsonConvert.SerializeObject(permit);
}
catch(TargetInvocationException e) { throw e.InnerException ?? e; }
}
public static string InspectPFBackup(string path) public static string InspectPFBackup(string path)
{ {
if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating) if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating)
@@ -57,29 +69,6 @@ public static class KVGameplayLoopSetup
catch(TargetInvocationException e) { throw e.InnerException ?? e; } catch(TargetInvocationException e) { throw e.InnerException ?? e; }
} }
public static string PreparePermit(string account,string backupPath,string[] allowedKeys)
=> PreparePermitCore(account,backupPath,allowedKeys,false);
public static string PrepareFishingPermit(string account,string backupPath,string[] allowedKeys)
=> PreparePermitCore(account,backupPath,allowedKeys,true);
public static string PrepareFishingRewardPermit(string account,string backupPath,string[] allowedKeys)
=> PreparePermitCore(account,backupPath,allowedKeys,true,true);
static string PreparePermitCore(string account,string backupPath,string[] allowedKeys,bool fishing,bool reward=false)
{
if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling)
throw new InvalidOperationException("Stop play before preparing a new process run.");
backupPath=Path.GetFullPath(backupPath);
var bytes=File.ReadAllBytes(backupPath);
string hash;
using(var sha=SHA256.Create()) hash=BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-","");
var candidate=Path.Combine(Root,"permit.candidate.json");
Directory.CreateDirectory(Root);
var json=JObject.FromObject(new{Version=reward?3:fishing?2:1,Enabled=true,PlayerId=account,
Api="http://s101.local:18080/",BackupPath=backupPath,BackupSha256=hash,AllowedKeys=allowedKeys});
if(fishing) json["Operation"]=reward?"fishing-reward-once":"fishing-consume-once";
File.WriteAllText(candidate,json.ToString(Formatting.Indented));
var validated=ValidatePermit(candidate); // Empty/mismatched backups cannot enable a run.
File.WriteAllText(Path.Combine(Root,"permit.json"),validated);
return State();
}
} }
#endif #endif

View File

@@ -0,0 +1,632 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using game;
using game.Account;
using game.PlayerDataStorage;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
// Fixed B01 synthetic cases only. No global container replacement, permission UI or Play mode.
public static class KVPromptStateValidation
{
private static bool running;
private static object failedCleanup;
private static readonly BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic;
public static async Task<string> RunAsync(string caseId)
{
if ((caseId != "B01" && caseId != "CP3A" && caseId != "P6D") || EditorApplication.isPlayingOrWillChangePlaymode || running || failedCleanup != null)
{
throw new InvalidOperationException("B01 requires an idle Editor and completed prior cleanup.");
}
running = true;
var run = new FixedRun();
var staticField = typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic);
object previous = null;
var isolated = false;
var errors = new List<Exception>();
string result = null;
try
{
previous = staticField.GetValue(null);
staticField.SetValue(null, new RedPointManager());
isolated = true;
result = caseId == "P6D" ? await run.ExecuteP6D() : caseId == "B01" ? await run.Execute() : await run.ExecuteCP3A();
}
catch (Exception ex)
{
errors.Add(ex);
}
finally
{
try
{
await run.Close();
}
catch (Exception ex)
{
failedCleanup = run;
errors.Add(ex);
}
try
{
if (isolated)
{
staticField.SetValue(null, previous);
if (!ReferenceEquals(previous, staticField.GetValue(null)))
{
throw new InvalidOperationException("Red point instance was not restored.");
}
}
}
catch (Exception ex)
{
failedCleanup = run;
errors.Add(ex);
}
finally
{
running = false;
}
}
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
var report = Newtonsoft.Json.Linq.JObject.Parse(result);
report["restored"] = true;
report["remainingTestObjects"] = 0;
return report.ToString(Formatting.None);
}
sealed class Handler : HttpMessageHandler
{
readonly Func<HttpRequestMessage, HttpResponseMessage> send;
internal Handler(Func<HttpRequestMessage, HttpResponseMessage> send) { this.send = send; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken t) => Task.FromResult(send(r));
}
static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK)
{ Content = new StringContent(JsonConvert.SerializeObject(value)) };
sealed class FixedRun
{
private readonly List<FixedRun> children = new List<FixedRun>();
private readonly List<Action> restoreFaults = new List<Action>();
readonly Uri api = new Uri("http://b01-editor.invalid/");
readonly string cache = Path.GetFullPath(Path.Combine(Application.dataPath,
"../../CodexOutput/upserver/B3BatchMigration/B01Tests/unity-runs", Guid.NewGuid().ToString("N")));
readonly List<S101AccountSession> sessions = new List<S101AccountSession>();
readonly List<GameObject> objects = new List<GameObject>();
PlayerKVGameSessionBridge bridge;
long epoch = 1;
int assertions, requests;
void Check(bool value, string label) { assertions++; if (!value) throw new InvalidOperationException("B01: " + label); }
static object Call(object target, string method, params object[] args)
{
try { return target.GetType().GetMethod(method, PrivateInstance | BindingFlags.Public).Invoke(target, args); }
catch (TargetInvocationException ex) { ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); throw; }
}
static void Field(object target, string name, object value) => target.GetType().GetField(name, PrivateInstance).SetValue(target, value);
static T Bound<T>(PlayerKVGameSessionHandle handle) => (T)typeof(T).GetConstructor(PrivateInstance,
null, new[] { typeof(PlayerKVGameSessionHandle) }, null).Invoke(new object[] { handle });
void Reject(Action action)
{
try { action(); }
catch (InvalidOperationException) { assertions++; return; }
throw new InvalidOperationException("B01: expected owner rejection");
}
async Task<PlayerKVGameSessionHandle> Open()
{
var session = new S101AccountSession(api, jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "account/Login"), "synthetic login only");
jar.Add(api, new Cookie("sid", "101"));
return Json(new { state = 0, internalId = 101, newCreate = false,
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" });
}));
sessions.Add(session);
Check((await session.LoginAsync("101")).Success, "synthetic login");
var opened = await bridge.OpenAsync(session, cache, "101", api, epoch, e => e == epoch);
Check(opened.Success, "synthetic owner open");
return opened.Handle;
}
T Panel<T>() where T : Component
{
var go = new GameObject("B01-" + typeof(T).Name) { hideFlags = HideFlags.HideAndDontSave };
go.SetActive(false); objects.Add(go);
return go.AddComponent<T>();
}
internal async Task<string> Execute()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "no write/no real transport");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), typeof(bool) }, null).Invoke(new object[] { factory, false });
var handle = await Open();
var settings = Bound<DefaultSettingService>(handle);
var club = Bound<ClubService>(handle);
Check(!settings.ShowNotice, "valid missing key");
settings.ShowNotice = true;
Check(settings.ShowNotice && requests == 1, "accepted local only");
var original = settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings);
settings.ShowNotice = true;
Check(ReferenceEquals(original, settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings)), "same value no duplicate");
// A fresh real ClubService retains its original initialized flag.
Check(club.IsOpenClubRed, "fresh real club retains original flag");
await bridge.CloseAsync();
Reject(() => settings.ShowNotice = false);
Reject(club.SetOpenClubRed);
Check(club.IsOpenClubRed, "stale real club preserves flag");
// Notification constructor validates the current owner. Create a fresh fixed instance, then revoke it.
epoch++;
var nextHandle = await Open();
var nextSettings = Bound<DefaultSettingService>(nextHandle);
var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText(
Path.Combine(Application.dataPath, "../../cfgData", name + ".json"))));
var notifications = (LocalNotificationService)typeof(LocalNotificationService).GetConstructor(PrivateInstance,
null, new[] { typeof(DefaultSettingService), typeof(cfg.Tables) }, null).Invoke(new object[] { nextSettings, tables });
var pendingInit = notifications.Init();
await bridge.CloseAsync();
try { await pendingInit; throw new InvalidOperationException("B01: stale init succeeded"); }
catch (InvalidOperationException ex) { Check(!ex.Message.Contains("stale init succeeded"), "stale Editor permission continuation rejected"); }
var grade = Panel<SettingNotificationsGradePopupPanel>();
Field(grade, "settings", nextSettings); Field(grade, "notifications", notifications);
var popup = Panel<SettingNotificationsPopupPanel>(); Field(popup, "notifications", notifications);
var settingPanel = Panel<SettingPopupPanel>(); Field(settingPanel, "settingService", nextSettings); Field(settingPanel, "notificationService", notifications);
var gift = Panel<GiftNoticePanel>(); Field(gift, "notificationService", notifications);
var social = Panel<FishingSocialPanel>(); Field(social, "_socialData", club);
var errors = new List<string>();
void Capture(string message, string stack, LogType type) { if (type == LogType.Error || type == LogType.Exception) errors.Add(message); }
Application.logMessageReceived += Capture;
try
{
Reject(() => Call(grade, "DestroyUI"));
Reject(() => Call(grade, "OnBtnOk"));
Reject(() => Call(popup, "OnBtnOk"));
Reject(() => Call(popup, "DestroyUI"));
Reject(() => Call(settingPanel, "OnToggleNotification", true));
Reject(() => Call(settingPanel, "OnToggleNotification", false));
Reject(() => Call(gift, "OnClickYES"));
Call(social, "Start");
Check(errors.Count == 1, "only unchanged social entry logs its owner rejection");
Check(errors.TrueForAll(e => e.Contains("ShowNotice owner=") || e.Contains("IsOpenClubRed owner=")), "no unexpected exception");
foreach (var go in objects) Check(go != null, "stale callback did not destroy another UI");
}
finally { Application.logMessageReceived -= Capture; }
Check(requests == 2, "read-only synthetic transport");
return JsonConvert.SerializeObject(new { success = true, assertions, requests, formalRequests = 0,
playing = EditorApplication.isPlaying, globalContainerChanged = false, caseId = "B01", cache });
}
internal async Task<string> ExecuteP6D()
{
foreach (var value in new[] { null, "0", "1", "" })
{
var fixture = new FixedRun();
children.Add(fixture);
await fixture.HealthyPrompt(value);
}
foreach (var club in new[] { false, true })
{
foreach (var stage in new[] { "BeforeWrite", "AfterReplace" })
{
var fixture = new FixedRun();
children.Add(fixture);
await fixture.FaultPrompt(club, stage);
}
var revoked = new FixedRun();
children.Add(revoked);
await revoked.RevokedDuringPrompt(club);
}
var callback = new FixedRun();
children.Add(callback);
await callback.RevokedDuringClubCallback();
foreach (var fixture in children)
{
assertions += fixture.assertions;
requests += fixture.requests;
}
return JsonConvert.SerializeObject(new { success = true, caseId = "P6D", assertions,
cases = children.Count, requests, formalRequests = 0, playing = EditorApplication.isPlaying,
globalContainerChanged = false });
}
private async Task<PlayerKVGameSessionHandle> OpenPrompt()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "P6D synthetic Get only");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), typeof(bool) }, null).Invoke(new object[] { factory, false });
return await Open();
}
private static PlayerKVEditResult Edit(object service)
{
return (PlayerKVEditResult)service.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(service);
}
private static bool? Cached(DefaultSettingService settings)
{
return (bool?)typeof(DefaultSettingService).GetField("_showNotice", PrivateInstance).GetValue(settings);
}
private sealed class RedProbe : IUIRedPoint
{
private readonly Action<bool> callback;
internal RedProbe(Action<bool> callback)
{
this.callback = callback;
}
public void SetRedPointState(bool state)
{
callback(state);
}
}
private void ObserveRed(Action<bool> callback)
{
RedPointManager.Instance.RemoveRedPoint(RedPointName.Home_Club);
RedPointManager.Instance.AddRedPoint(RedPointName.Home_Club, new RedProbe(callback));
}
private static Delegate FaultDelegate<T>(Action<string> callback)
{
return new Action<T>(stage => callback(stage.ToString()));
}
private void InjectFault(PlayerKVGameSessionHandle handle, Action<string> callback)
{
var owner = handle.GetType().GetProperty("Owner", PrivateInstance).GetValue(handle);
var state = owner.GetType().GetField("state", PrivateInstance).GetValue(owner);
var store = state.GetType().GetField("store", PrivateInstance).GetValue(state);
var field = store.GetType().GetField("fault", PrivateInstance);
var previous = field.GetValue(store);
var method = typeof(FixedRun).GetMethod("FaultDelegate", BindingFlags.Static | BindingFlags.NonPublic)
.MakeGenericMethod(field.FieldType.GetGenericArguments()[0]);
var fault = method.Invoke(null, new object[] { callback });
restoreFaults.Add(() => field.SetValue(store, previous));
field.SetValue(store, fault);
Check(ReferenceEquals(field.GetValue(store), fault), "actual cache fault installed");
}
private async Task HealthyPrompt(string initial)
{
var handle = await OpenPrompt();
if (initial != null)
{
Check(handle.Update("ShowNotice", initial).Accepted, "seed initial value");
}
var settings = Bound<DefaultSettingService>(handle);
var expected = initial == "1";
Check(settings.ShowNotice == expected, "first read follows original string comparison");
Check(handle.Update("ShowNotice", expected ? "0" : "1").Accepted, "other consumer updates actual cache");
Check(settings.ShowNotice == expected, "first-read nullable cache survives other consumer writes");
var fresh = Bound<DefaultSettingService>(handle);
var target = !expected;
var observedWrites = 0;
InjectFault(handle, stage =>
{
if (stage == "BeforeWrite")
{
observedWrites++;
Check(Cached(fresh) == target, "nullable cache assigned before actual disk write");
}
});
fresh.ShowNotice = target;
Check(Edit(fresh)?.Accepted == true && observedWrites == 1, "setter before getter writes even when stored value matches");
var receipt = Edit(fresh);
fresh.ShowNotice = target;
Check(ReferenceEquals(receipt, Edit(fresh)) && observedWrites == 1, "same cached value does not write");
target = !target;
fresh.ShowNotice = target;
Check(observedWrites == 2 && !ReferenceEquals(receipt, Edit(fresh)), "changed cached value writes");
RestoreFaults();
Check(handle.Update("IsOpenClubRed", "0").Accepted, "seed present club value");
var club = Bound<ClubService>(handle);
var calls = 0;
ObserveRed(red =>
{
calls++;
Check(!red && !club.IsOpenClubRed, "flag clears before red callback");
Check(handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == (calls == 1 ? "0" : "1"), "red callback precedes write");
});
club.SetOpenClubRed();
Check(calls == 1 && Edit(club)?.Accepted == true && handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == "1", "present key does not replace original flag gate");
var clubReceipt = Edit(club);
club.SetOpenClubRed();
Check(calls == 1 && ReferenceEquals(clubReceipt, Edit(club)), "false flag skips red and write");
club.IsOpenClubRed = true;
club.SetOpenClubRed();
Check(calls == 2 && !ReferenceEquals(clubReceipt, Edit(club)), "true flag writes existing value");
// A diagnostic result is not a second owner state. Do not unlock a real failed owner.
var rejected = handle.Update(null, "1");
Check(!rejected.Accepted, "actual rejected diagnostic");
fresh.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(fresh, rejected);
club.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(club, rejected);
Check(fresh.ShowNotice == target, "healthy owner ignores old rejected diagnostic");
club.SetOpenClubRed();
Check(calls == 2, "healthy false club ignores diagnostic without business effects");
await bridge.CloseAsync();
Reject(() =>
{
_ = fresh.ShowNotice;
});
Reject(() => fresh.ShowNotice = target);
Reject(club.SetOpenClubRed);
Check(calls == 2 && Cached(fresh) == target && !club.IsOpenClubRed, "stale cache-hit entries cause no effects");
}
private async Task FaultPrompt(bool useClub, string failureStage)
{
var handle = await OpenPrompt();
var settings = Bound<DefaultSettingService>(handle);
var club = Bound<ClubService>(handle);
var redCalls = 0;
ObserveRed(red =>
{
redCalls++;
Check(!red && !club.IsOpenClubRed, "failed write still follows original red order");
});
var faults = 0;
InjectFault(handle, stage =>
{
if (stage == failureStage)
{
faults++;
Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true,
"business state precedes actual storage failure");
throw new IOException("P6D synthetic " + failureStage);
}
});
Reject(() =>
{
if (useClub)
{
club.SetOpenClubRed();
}
else
{
settings.ShowNotice = true;
}
});
var edit = Edit(useClub ? (object)club : settings);
Check(faults == 1 && edit != null && !edit.Accepted, "actual rejected write preserved");
Check(edit.StateResult.Error == PlayerKVChangeError.StorageError &&
edit.StateResult.LocalCommitMayHaveSucceeded == (failureStage == "AfterReplace"), "actual uncertainty preserved");
Check(handle.Status == PlayerKVSyncStatus.StorageError, "actual storage terminal state retained");
Reject(() =>
{
_ = settings.ShowNotice;
});
Reject(() => settings.ShowNotice = true);
Reject(club.SetOpenClubRed);
Check(faults == 1 && redCalls == (useClub ? 1 : 0), "actual unreadable owner blocks repeat effects");
Check(useClub ? !club.IsOpenClubRed : Cached(settings) == true, "no added rollback");
}
private async Task RevokedDuringPrompt(bool useClub)
{
var handle = await OpenPrompt();
var settings = Bound<DefaultSettingService>(handle);
var club = Bound<ClubService>(handle);
var redCalls = 0;
ObserveRed(red =>
{
redCalls++;
});
InjectFault(handle, stage =>
{
if (stage == "AfterReplace")
{
epoch++;
}
});
Reject(() =>
{
if (useClub)
{
club.SetOpenClubRed();
}
else
{
settings.ShowNotice = true;
}
});
Check(Edit(useClub ? (object)club : settings)?.Accepted == true, "completed disk write not disguised as unaccepted");
Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true, "revocation does not roll back original front effects");
Check(!handle.ReadLocalSnapshot().Success && requests == 1, "revoked old owner has no replay or transport write");
}
private async Task RevokedDuringClubCallback()
{
var handle = await OpenPrompt();
var club = Bound<ClubService>(handle);
var redCalls = 0;
ObserveRed(red =>
{
redCalls++;
epoch++;
});
var writes = 0;
InjectFault(handle, stage =>
{
writes++;
});
Reject(club.SetOpenClubRed);
Check(!club.IsOpenClubRed && redCalls == 1, "callback revocation preserves front effects");
Check(Edit(club)?.Accepted == false && writes == 0 && requests == 1, "callback revocation refuses old write without replay");
}
internal async Task<string> ExecuteCP3A()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "CP3A synthetic Get only");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), typeof(bool) }, null).Invoke(new object[] { factory, false });
var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText(
Path.Combine(Application.dataPath, "../../cfgData", name + ".json"))));
var settingCtor = typeof(DefaultSettingService).GetConstructor(PrivateInstance,
null, new[] { typeof(bool) }, null);
var clubCtor = typeof(ClubService).GetConstructor(PrivateInstance,
null, new[] { typeof(bool) }, null);
var notificationCtor = typeof(LocalNotificationService).GetConstructor(PrivateInstance,
null, new[] { typeof(cfg.Tables) }, null);
Check(settingCtor != null && clubCtor != null && notificationCtor != null,
"formal service constructors exist");
var settings = (DefaultSettingService)settingCtor.Invoke(new object[] { true });
var club = (ClubService)clubCtor.Invoke(new object[] { true });
var notifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables });
var pendingNotifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables });
var pendingInit = pendingNotifications.Init();
Reject(() => Call(pendingNotifications, "PreflightFormalPromptSettings", settings));
await pendingInit;
await notifications.Init();
Reject(() => { _ = settings.ShowNotice; });
Reject(() => settings.ShowNotice = true);
Reject(club.SetOpenClubRed);
var handle = await Open();
Call(settings, "BindFormalPromptOwner", handle);
Call(club, "BindFormalPromptOwner", handle);
Call(notifications, "BindFormalPromptSettings", settings);
Call(settings, "BindFormalPromptOwner", handle);
Call(club, "BindFormalPromptOwner", handle);
Call(notifications, "BindFormalPromptSettings", settings);
Check(!settings.ShowNotice, "bound missing ShowNotice");
settings.ShowNotice = true;
club.SetOpenClubRed();
Check(settings.ShowNotice && !club.IsOpenClubRed, "bound prompt effects");
Check(requests == 1, "one synthetic KV Get and no transport write");
await bridge.CloseAsync();
Reject(() => { _ = settings.ShowNotice; });
Reject(club.SetOpenClubRed);
epoch++;
var nextHandle = await Open();
Reject(() => Call(settings, "BindFormalPromptOwner", nextHandle));
Reject(() => Call(club, "BindFormalPromptOwner", nextHandle));
var partialSetting = (DefaultSettingService)settingCtor.Invoke(new object[] { true });
var partialClub = (ClubService)clubCtor.Invoke(new object[] { true });
Call(partialSetting, "BindFormalPromptOwner", nextHandle);
await bridge.CloseAsync();
Reject(() => Call(partialClub, "BindFormalPromptOwner", nextHandle));
Reject(() => { _ = partialSetting.ShowNotice; });
epoch++;
var thirdHandle = await Open();
Reject(() => Call(partialSetting, "BindFormalPromptOwner", thirdHandle));
Check(requests == 3, "same owner binds and partial failure add no transport requests");
return JsonConvert.SerializeObject(new { success = true, caseId = "CP3A", assertions,
requests, formalRequests = 0, globalContainerChanged = false });
}
internal async Task Close()
{
var errors = new List<Exception>();
foreach (var child in children)
{
try
{
await child.Close();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
try
{
RestoreFaults();
}
catch (Exception ex)
{
errors.Add(ex);
}
try
{
if (bridge != null)
{
await bridge.CloseAsync();
}
}
catch (Exception ex)
{
errors.Add(ex);
}
foreach (var session in sessions)
{
try
{
session.Dispose();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
foreach (var go in objects)
{
try
{
if (go != null)
{
UnityEngine.Object.DestroyImmediate(go);
}
if (go != null)
{
throw new InvalidOperationException("Test object survived cleanup.");
}
}
catch (Exception ex)
{
errors.Add(ex);
}
}
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
}
private void RestoreFaults()
{
var errors = new List<Exception>();
foreach (var restore in restoreFaults)
{
try
{
restore();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
restoreFaults.Clear();
}
}
}
#endif

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67d5bde18c99423aa2168a5af1c2b0da

View File

@@ -1,19 +0,0 @@
#if UNITY_EDITOR
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
public static class KVSaveRouteValidation
{
public static async Task<string> RunAsync(string caseId)
{
if (caseId != "fixed-route") throw new ArgumentException("Unknown fixed validation case.");
var assembly = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "HotUpdate");
var type = assembly.GetType("game.PlayerDataStorage.PlayerKVSaveRouteEditorValidationHost", true);
var method = type.GetMethod("RunAsync", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null);
if (method == null) throw new InvalidOperationException("Validation entry unavailable.");
return await (Task<string>)method.Invoke(null, new object[] { caseId });
}
}
#endif

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dc9b5dbb615a4746a95df66b46987589

View File

@@ -4068,7 +4068,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0} m_AnchorMin: {x: 0.5, y: 0}
m_AnchorMax: {x: 0.5, y: 0} m_AnchorMax: {x: 0.5, y: 0}
m_AnchoredPosition: {x: 0, y: 109.05} m_AnchoredPosition: {x: 0, y: 109.049805}
m_SizeDelta: {x: 0, y: 74.69995} m_SizeDelta: {x: 0, y: 74.69995}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &2775237510373940736 --- !u!1 &2775237510373940736

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using game; using game;
using GameCore; using GameCore;
@@ -104,23 +104,10 @@ public class CampDataMM
public bool IsFix => _isFix && AreaIndex < AreaList.Count; public bool IsFix => _isFix && AreaIndex < AreaList.Count;
bool _isFix; bool _isFix;
internal static game.PlayerDataStorage.PlayerKVCampState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeCamp(raw,errors);
internal void ResetForKVLoad()
{
CampDataDic=new Dictionary<string,int>(StringComparer.Ordinal);
_isFix=false; _isOpenSkyscraper="0"; _id=101;
PreID=AreaIndex=AreaStep=AreaId=AreaStepCount=TimelineId=0;
AreaIcon=null; constructionArea=null; StepInfo=null; AreaStepList=null; IsUpdataArea=false;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCampState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
CampDataDic=state.Values.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
_isFix=state.IsFix; _isOpenSkyscraper=state.Skyscraper;
}
internal game.PlayerDataStorage.PlayerKVCampState SnapshotForKVValidation() =>
new game.PlayerDataStorage.PlayerKVCampState(_isFix,_isOpenSkyscraper,CampDataDic);
public void InitData(Dictionary<string, string> userData) public void InitData(Dictionary<string, string> userData)
{ {
string value; string value;

View File

@@ -1,19 +1,156 @@
using System;
using UnityEngine;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using game.PlayerDataStorage; using game.PlayerDataStorage;
internal static class KVMgr internal class KVMgr : MonoBehaviour
{ {
private static KVMgr instance;
private KVPlayerSaveStore store;
private CancellationTokenSource tokenSource;
private void Awake()
{
instance = this;
tokenSource = new CancellationTokenSource();
UpdateUserDataValue(tokenSource.Token);
}
internal static void Bind(KVPlayerSaveStore original)
{
if (instance != null)
{
instance.store = original;
}
}
internal static void Unbind(KVPlayerSaveStore original)
{
if (instance != null && ReferenceEquals(instance.store, original))
{
instance.store = null;
}
}
internal static void Stop(KVPlayerSaveStore original)
{
if (instance != null && ReferenceEquals(instance.store, original))
{
instance.Stop();
}
}
private void Stop()
{
tokenSource?.Cancel();
tokenSource?.Dispose();
tokenSource = null;
}
private async void UpdateUserDataValue(CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
SyncData();
await Task.Delay(40000);
}
}
catch (Exception ex)
{
Debug.LogError("KV save schedule failed: " + ex.GetType().Name);
}
}
private void SyncData()
{
var original = store;
if (original != null && original.IsCurrent)
{
ObserveSync(original.SyncDataAsync());
}
}
private static async void ObserveSync(Task<PlayerKVFlushResult> task)
{
try
{
var result = await task;
if (result.TransportError != PlayerKVError.None || result.Blocked != 0 || result.Unknown != 0)
{
Debug.LogError("KV ordinary save failed: " + result.TransportError);
}
}
catch (Exception ex)
{
Debug.LogError("KV ordinary save failed: " + ex.GetType().Name);
}
}
private void OnApplicationPause(bool paused)
{
if (paused)
{
Stop();
SyncData();
}
else
{
tokenSource = new CancellationTokenSource();
UpdateUserDataValue(tokenSource.Token);
}
}
private void OnApplicationQuit()
{
Stop();
}
private void OnDestroy()
{
Stop();
SyncData();
store = null;
if (ReferenceEquals(instance, this))
{
instance = null;
}
}
internal static Task<PlayerKVFlushResult> SyncKVAsync(PlayerKVGameSessionHandle handle,
CancellationToken waitToken = default)
{
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync))
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled,
handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status));
}
if (handle == null)
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded));
}
return handle.SyncDataAsync(waitToken);
}
internal static PlayerKVKeyReadResult ReadKV(PlayerKVGameSessionHandle handle, string key) internal static PlayerKVKeyReadResult ReadKV(PlayerKVGameSessionHandle handle, string key)
{ {
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal)) if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal))
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.LoadGuardActive, handle); {
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.LoadGuardActive, handle);
}
if (handle == null || !KVSnapshot.ValidKey(key)) if (handle == null || !KVSnapshot.ValidKey(key))
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.InvalidInput, handle); {
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.InvalidInput, handle);
}
var read = handle.ReadLocalSnapshot(); var read = handle.ReadLocalSnapshot();
if (!read.Success) if (!read.Success)
return new PlayerKVKeyReadResult(read.Error == PlayerKVLocalReadError.StaleOrClosing {
? PlayerKVKeyReadError.StaleOrClosing : PlayerKVKeyReadError.NotReady, handle); return new PlayerKVKeyReadResult(read.Error == PlayerKVLocalReadError.StaleOrClosing
? PlayerKVKeyReadError.StaleOrClosing : PlayerKVKeyReadError.NotReady, handle);
}
var found = read.Data.TryGetValue(key, out var value); var found = read.Data.TryGetValue(key, out var value);
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.None, handle, found, value); return new PlayerKVKeyReadResult(PlayerKVKeyReadError.None, handle, found, value);
} }
@@ -22,20 +159,58 @@ internal static class KVMgr
string key, string value, bool remoteStore = true) string key, string value, bool remoteStore = true)
{ {
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update)) if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update))
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked)); {
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
}
if (handle == null) if (handle == null)
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Validation)); {
return handle.Update(key, value, remoteStore); return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Validation));
}
return handle.Update(key, value, remoteStore, ZZTimeHelper.UtcNow().Ticks);
}
internal static async Task<bool> SyncDataImmidiate(PlayerKVGameSessionHandle handle, string key, string value)
{
var success = false;
if (!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate) && handle != null &&
KVSnapshot.ValidKey(key) && value != null)
{
success = await handle.SyncDataImmidiate(key, value);
}
if (!success)
{
UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed.");
}
return success;
}
internal static async Task<bool> SyncDataImmidiate(PlayerKVGameSessionHandle handle,
IReadOnlyDictionary<string, string> values)
{
var success = false;
if (!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate) && handle != null && values != null && values.Count > 0)
{
success = await handle.SyncDataImmidiate(values);
}
if (!success)
{
UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed.");
}
return success;
} }
internal static Task<PlayerKVFlushResult> FlushKVAsync(PlayerKVGameSessionHandle handle, internal static Task<PlayerKVFlushResult> FlushKVAsync(PlayerKVGameSessionHandle handle,
CancellationToken waitToken = default) CancellationToken waitToken = default)
{ {
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync))
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, {
handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status)); return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled,
handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status));
}
if (handle == null) if (handle == null)
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded)); {
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded));
}
return handle.FlushAsync(waitToken); return handle.FlushAsync(waitToken);
} }
@@ -43,10 +218,14 @@ internal static class KVMgr
PlayerKVSyncTicket ticket, CancellationToken waitToken = default) PlayerKVSyncTicket ticket, CancellationToken waitToken = default)
{ {
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync))
return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled, {
handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status)); return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled,
handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status));
}
if (handle == null) if (handle == null)
return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded)); {
return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded));
}
return handle.WaitForTicketAsync(ticket, waitToken); return handle.WaitForTicketAsync(ticket, waitToken);
} }
} }

View File

@@ -26,8 +26,6 @@ public class PlayFabMgr : MonoBehaviour
private CancellationTokenSource tokenSource; private CancellationTokenSource tokenSource;
static PlayFabMgr _instance = null; static PlayFabMgr _instance = null;
// An inactive, never-awakened component proves that no timer or PF send has ever been started.
internal bool IsUnstartedForKVLoad => tokenSource==null && dataBuffer==null && swapData==null && localData==null && lastUpdateTime==null && _instance!=this;
internal static bool HasLiveInstanceForKVLoad => _instance!=null; internal static bool HasLiveInstanceForKVLoad => _instance!=null;
public static PlayFabMgr Instance public static PlayFabMgr Instance
@@ -45,9 +43,6 @@ public class PlayFabMgr : MonoBehaviour
public Dictionary<string, string> CompareDatas(Dictionary<string, UserDataRecord> remoteDatas, string userId) public Dictionary<string, string> CompareDatas(Dictionary<string, UserDataRecord> remoteDatas, string userId)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("PF archive comparison is disabled in KV loop.");
#endif
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Compare); PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Compare);
Last_Update_Time_Key = $"{Last_Update_Time_Key_Prefix}_{userId}"; Last_Update_Time_Key = $"{Last_Update_Time_Key_Prefix}_{userId}";
Local_Data_Key = $"{Local_Data_Key_prefix}_{userId}"; Local_Data_Key = $"{Local_Data_Key_prefix}_{userId}";
@@ -124,24 +119,11 @@ public class PlayFabMgr : MonoBehaviour
{ {
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
_instance = this; _instance = this;
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) return;
#endif
InitBuffer(); InitBuffer();
tokenSource = new CancellationTokenSource(); tokenSource = new CancellationTokenSource();
UpdateUserDataValue(tokenSource.Token); UpdateUserDataValue(tokenSource.Token);
} }
#if UNITY_EDITOR && !SERVICE_HOST
internal void StartKVGameplayLoopSync()
{
if(!PlayerKVGameplayLoop.Ready || tokenSource!=null) return;
tokenSource=new CancellationTokenSource();
UpdateUserDataValue(tokenSource.Token);
}
internal void StopKVGameplayLoopSync()
{ tokenSource?.Cancel(); tokenSource?.Dispose(); tokenSource=null; }
#endif
void InitBuffer() void InitBuffer()
{ {
dataBuffer = new LinkedList<Dictionary<string, string>>(); dataBuffer = new LinkedList<Dictionary<string, string>>();
@@ -154,14 +136,6 @@ public class PlayFabMgr : MonoBehaviour
void OnApplicationPause(bool is_pause) void OnApplicationPause(bool is_pause)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled)
{
if(is_pause) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.FlushObservedAsync(); }
else StartKVGameplayLoopSync();
return;
}
#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
if (is_pause) if (is_pause)
{ {
@@ -178,9 +152,6 @@ public class PlayFabMgr : MonoBehaviour
void OnApplicationQuit() void OnApplicationQuit()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }
#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
tokenSource.Cancel(); tokenSource.Cancel();
tokenSource = null; tokenSource = null;
@@ -189,9 +160,6 @@ public class PlayFabMgr : MonoBehaviour
void OnDestroy() void OnDestroy()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }
#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return; if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
tokenSource?.Cancel(); tokenSource?.Cancel();
tokenSource = null; tokenSource = null;
@@ -215,9 +183,6 @@ public class PlayFabMgr : MonoBehaviour
public void UpdateUserDataValue(string key, string data, bool remoteStore = true) public void UpdateUserDataValue(string key, string data, bool remoteStore = true)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) { PlayerKVGameplayLoop.Write(key,data,remoteStore); return; }
#endif
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update); PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update);
if (remoteStore) if (remoteStore)
{ {
@@ -248,9 +213,6 @@ public class PlayFabMgr : MonoBehaviour
/// <returns></returns> /// <returns></returns>
public string GetLocalData(string key) public string GetLocalData(string key)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) return PlayerKVGameplayLoop.Read(key);
#endif
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal); PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal);
if (localData.ContainsKey(key)) if (localData.ContainsKey(key))
{ {
@@ -264,9 +226,6 @@ public class PlayFabMgr : MonoBehaviour
private void SyncData() private void SyncData()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) { _=PlayerKVGameplayLoop.FlushObservedAsync(); return; }
#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) return; if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) return;
// Logout clears credentials before RootCtx is destroyed. Keep pending data local. // Logout clears credentials before RootCtx is destroyed. Keep pending data local.
if (!PlayFabClientAPI.IsClientLoggedIn()) return; if (!PlayFabClientAPI.IsClientLoggedIn()) return;
@@ -320,13 +279,9 @@ public class PlayFabMgr : MonoBehaviour
GameDebug.LogError("[PlayFabMgr]UpdateUserData Error: {0}", error.ToString()); GameDebug.LogError("[PlayFabMgr]UpdateUserData Error: {0}", error.ToString());
} }
internal void FlushBufferedData() => SyncData();
public async Task<bool> SyncDataImmidiate(string key, string data) public async Task<bool> SyncDataImmidiate(string key, string data)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("Immediate/consuming saves are outside the equipment loop.");
#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate)) return false; if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate)) return false;
foreach (var buff in dataBuffer) foreach (var buff in dataBuffer)
{ {

View File

@@ -32,30 +32,14 @@ public class AchievementDataManager : IDisposable
{ {
_tables = tables; _tables = tables;
} }
internal static game.PlayerDataStorage.PlayerKVAchievementState DecodeForKVLoad(
IReadOnlyDictionary<string, string> raw, IReadOnlyDictionary<string, string> modern,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeAchievements(raw, modern, snapshot, errors);
internal void ResetForKVLoad()
{
_achievementDatas = new Dictionary<ConditionType, AchievementData>();
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVAchievementState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
var replacement = new Dictionary<ConditionType, AchievementData>();
foreach (var entry in state.Values)
replacement.Add((ConditionType)entry.Key, new AchievementData
{ Id = entry.Value.Id, Target = entry.Value.Target, day = entry.Value.Day });
_achievementDatas = replacement;
}
internal bool IsEmptyForKVValidation => _achievementDatas.Count == 0;
internal AchievementData FindForKVValidation(ConditionType key)
=> _achievementDatas.TryGetValue(key, out var value)
? new AchievementData { Id = value.Id, Target = value.Target, day = value.day } : null;
public void LoadAchievementData(string value) public void LoadAchievementData(string value)
{ {

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using GameCore; using GameCore;
using UnityEngine; using UnityEngine;
@@ -15,61 +15,13 @@ public class BargainPackData
private readonly TbSpecialPack _spp = GContext.container.Resolve<Tables>().TbSpecialPack; private readonly TbSpecialPack _spp = GContext.container.Resolve<Tables>().TbSpecialPack;
private GeneralEventPackData _data; private GeneralEventPackData _data;
private bool _kvLoadPresent;
#if UNITY_EDITOR
internal bool SelectionsAreIndependentForKVValidation()
{
var source = new List<int> { 7, 9 };
var state = new game.PlayerDataStorage.PlayerKVBargainState(true,0,_data.redirectID,0,0,0,0,0,0,0,0,0,false,false,0,source);
ReplaceFromKVDecoded(state);
source.Clear();
if (_data.selections.Count != 2 || ReferenceEquals(_data.selections, source) || ReferenceEquals(_data.selections, state.selections)) return false;
var previous = _data.selections;
_data.selections[0] = 10;
if (state.selections[0] != 7) return false;
ResetForKVLoad();
return _data.selections.Count == 0 && !ReferenceEquals(previous, _data.selections) && !_kvLoadPresent;
}
#endif
internal bool KVLoadPresent => _kvLoadPresent;
internal static game.PlayerDataStorage.PlayerKVBargainState DecodeForKVLoad(
IReadOnlyDictionary<string, string> raw, game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBargain(raw, snapshot, errors);
internal void ResetForKVLoad()
{
_kvLoadPresent = false;
_data = default(GeneralEventPackData);
_data.selections = new List<int>();
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBargainState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
if (!state.IsPresent) { ResetForKVLoad(); return; }
var replacement = new GeneralEventPackData
{
currentEventID = state.currentEventID,
redirectID = state.redirectID,
VIPLvlWhenEventActivate = state.VIPLvlWhenEventActivate,
purchaseCount = state.purchaseCount,
savingProgress = state.savingProgress,
discountLvl = state.discountLvl,
visualProgress = state.visualProgress,
visualDiscountLvl = state.visualDiscountLvl,
rodID = state.rodID,
lastPackIDBought = state.lastPackIDBought,
packID = state.packID,
doNeedTrigger = state.doNeedTrigger,
isTriggered = state.isTriggered,
inflation = state.inflation,
selections = state.selections == null ? null : new List<int>(state.selections),
};
_data = replacement;
_kvLoadPresent = true;
}
internal game.PlayerDataStorage.PlayerKVBargainState SnapshotForKVValidation() =>
new game.PlayerDataStorage.PlayerKVBargainState(_kvLoadPresent, _data.currentEventID, _data.redirectID, _data.VIPLvlWhenEventActivate, _data.purchaseCount, _data.savingProgress, _data.discountLvl, _data.visualProgress, _data.visualDiscountLvl, _data.rodID, _data.lastPackIDBought, _data.packID, _data.doNeedTrigger, _data.isTriggered, _data.inflation, _data.selections);
public int CurrentEventID => _data.currentEventID; public int CurrentEventID => _data.currentEventID;

View File

@@ -37,28 +37,9 @@ public class BattlePassData
public BattlePassRecord BattlePassRecord; public BattlePassRecord BattlePassRecord;
public BattleTask BattleTask; public BattleTask BattleTask;
internal static game.PlayerDataStorage.PlayerKVBattlePassState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBattlePass(raw,snapshot,errors);
internal void ResetForKVLoad()
{
BattlePassRecord=new BattlePassRecord();
BattleTask=new BattleTask();
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBattlePassState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
var record=new BattlePassRecord {
ID=state.Record.ID,Level=state.Record.Level,Exp=state.Record.Exp,BuyVipToastIndex=state.Record.BuyVipToastIndex,
IsUnlockVip=state.Record.IsUnlockVip,NormalRewardGet=new List<int>(state.Record.Normal),VipRewardGet=new List<int>(state.Record.Vip),
LastDropIDs=new List<int>(state.Record.LastDrops),InflationRate=state.Record.Inflation
};
var task=new BattleTask {
dic_dailyTasks=state.Task.Daily.ToDictionary(p=>p.Key,p=>p.Value),dic_weeklyTasks=state.Task.Weekly.ToDictionary(p=>p.Key,p=>p.Value),
loginDays=new List<int>(state.Task.LoginDays),DailyTaskEndTime=state.Task.DailyEnd,WeeklyTaskEndTime=state.Task.WeeklyEnd
};
BattlePassRecord=record; BattleTask=task;
}
private Tables _tables; private Tables _tables;

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using GameCore; using GameCore;
using System; using System;
@@ -19,38 +19,10 @@ public class BuffDataCenter
FishBuffTimeData fishDailyBuff = null; FishBuffTimeData fishDailyBuff = null;
public List<FishBuffTimeData> showFishBuffIds = new List<FishBuffTimeData>(); public List<FishBuffTimeData> showFishBuffIds = new List<FishBuffTimeData>();
internal static game.PlayerDataStorage.PlayerKVBuffState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBuffs(raw,snapshot,errors);
internal void ResetForKVLoad()
{
mapBuffDataList=new Dictionary<int,FishBuffTimeData>();
targetBuffDataList=new List<FishBuffTimeData>();
globalBuffDataList=new Dictionary<int,FishBuffTimeData>();
showFishBuffIds=new List<FishBuffTimeData>(); fishDailyBuff=null;
}
static FishBuffTimeData MaterializeKVBuff(game.PlayerDataStorage.PlayerKVBuffValue value) => value==null ? null : new FishBuffTimeData
{ SortID=value.SortID,sourceID=value.sourceID,buffID=value.buffID,isEnd=value.isEnd,buffEndTime=value.buffEndTime,isNew=false,addDown=0 };
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBuffState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
var map=new Dictionary<int,FishBuffTimeData>(); var global=new Dictionary<int,FishBuffTimeData>(); var target=new List<FishBuffTimeData>();
foreach(var item in state.Map) map.Add(item.Key,MaterializeKVBuff(item.Value));
foreach(var item in state.Global) global.Add(item.Key,MaterializeKVBuff(item.Value));
foreach(var item in state.Target) target.Add(MaterializeKVBuff(item));
mapBuffDataList=map; globalBuffDataList=global; targetBuffDataList=target; fishDailyBuff=MaterializeKVBuff(state.Daily);
}
#if UNITY_EDITOR
internal FishBuffTimeData BuffForKVValidation(int kind,int key)
{
if(kind==0) return mapBuffDataList.TryGetValue(key,out var value) ? value : null;
if(kind==1) return targetBuffDataList.Count>key ? targetBuffDataList[key] : null;
if(kind==2) return fishDailyBuff;
return globalBuffDataList.TryGetValue(key,out var global) ? global : null;
}
internal bool IsEmptyForKVValidation => mapBuffDataList.Count==0 && targetBuffDataList.Count==0 &&
globalBuffDataList.Count==0 && showFishBuffIds.Count==0 && fishDailyBuff==null;
#endif
public void InitData(Dictionary<string, string> userDatas) public void InitData(Dictionary<string, string> userDatas)
{ {
if (userDatas.TryGetValue("BuffDataList", out string buffDataStr)) if (userDatas.TryGetValue("BuffDataList", out string buffDataStr))

View File

@@ -1,4 +1,4 @@
using Activity.Services; using Activity.Services;
using asap.core; using asap.core;
using Assets.Scripts.UI.SocialRush; using Assets.Scripts.UI.SocialRush;
using cfg; using cfg;
@@ -141,45 +141,9 @@ namespace GameCore
//待释放 //待释放
IDisposable disposable; IDisposable disposable;
internal static game.PlayerDataStorage.PlayerKVEventBaseState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
IReadOnlyDictionary<string,string> modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeEventBase(raw,modern,snapshot,errors);
internal void ResetForKVLoad()
{
limitedTimeEventDic=new Dictionary<int,LimitedTimeEvent>(); fixedTimeEventDic=new Dictionary<int,FixedTimeEvent>();
ETDropAfterDropDic=new Dictionary<int,int>(); RankTargetCount=new Dictionary<int,int>(); WheelLevelCount=new Dictionary<int,int>();
TurntableCoupon=_tables.TbGlobalConfig.InitWheelTicket; WheelOperateCount=0; AllWheelOperateCount=0; freecount=0; PVPToken=0;
collectingTargetEventID=-1; virtualCollectingTargetEventID=-1; NewPlayerEvent=default;
collectingTargetInit=null; collectingTargetInitTokenID=0; collectingTargetInitTaskID=0;
TargetList=null; collectingTarget=null; preCollectingTarget=null; targetItemDatas=null; preRankTarget=null; rankItemDatas=null;
rankEvent=null; rankInit=null; rankTarget=null; turntableEvent=null; wheelInit=null; wheelLevel=null;
SystemOpen=new Dictionary<int,bool>(); _unlocked=new HashSet<int>(); _isTargetGuidance=true;
IsJoinUsPanelOpen=false; collectingTargetResoure=true; IsAquariumOpen=false; IsOpenDrop=false; OpenAquariumID=0;
requiredCount=0; requiredRankCount=0; curActivateItemId=0;
duelData=null; rankTierChangeState=0; curSolomain=null; Pack1A1Data=null; Pack1A2Data=null;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEventBaseState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
limitedTimeEventDic=state.Limited.ToDictionary(p=>p.Key,p=>new LimitedTimeEvent { id=p.Value.Id,duration=p.Value.Duration,startTime=p.Value.StartTime });
fixedTimeEventDic=state.Fixed.ToDictionary(p=>p.Key,p=>new FixedTimeEvent { id=p.Value.id,endTime=p.Value.endTime });
ETDropAfterDropDic=state.Target.ToDictionary(p=>p.Key,p=>p.Value); RankTargetCount=state.Rank.ToDictionary(p=>p.Key,p=>p.Value);
WheelLevelCount=state.Wheel.ToDictionary(p=>p.Key,p=>p.Value); TurntableCoupon=state.Coupon;
WheelOperateCount=state.Operate; AllWheelOperateCount=state.AllOperate; freecount=state.Free; PVPToken=state.PvpToken;
}
#if UNITY_EDITOR
internal game.PlayerDataStorage.PlayerKVEventBaseState SnapshotForKVValidation() => new game.PlayerDataStorage.PlayerKVEventBaseState(
limitedTimeEventDic.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVLimitedValue(p.Value.id,p.Value.duration,p.Value.startTime)),
fixedTimeEventDic.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVFixedTimeValue(p.Value.id,p.Value.endTime)),
ETDropAfterDropDic,RankTargetCount,WheelLevelCount,TurntableCoupon,WheelOperateCount,AllWheelOperateCount,freecount,PVPToken);
internal void MutateCollectionsForKVValidation()
{ limitedTimeEventDic.Values.First().duration=99; fixedTimeEventDic.Values.First().id=99; ETDropAfterDropDic.Clear(); RankTargetCount.Clear(); WheelLevelCount.Clear(); }
internal bool IsInactiveForKVValidation => collectingTargetEventID==-1 && virtualCollectingTargetEventID==-1 && NewPlayerEvent==default &&
collectingTargetInit==null && collectingTargetInitTokenID==0 && collectingTargetInitTaskID==0 && TargetList==null && collectingTarget==null &&
preCollectingTarget==null && targetItemDatas==null && preRankTarget==null && rankItemDatas==null && rankEvent==null && rankInit==null && rankTarget==null &&
turntableEvent==null && wheelInit==null && wheelLevel==null && SystemOpen.Count==0 && _unlocked.Count==0 && duelData==null && curSolomain==null &&
Pack1A1Data==null && Pack1A2Data==null && !IsJoinUsPanelOpen && !IsAquariumOpen && !IsOpenDrop && OpenAquariumID==0 && requiredCount==0 && requiredRankCount==0 && curActivateItemId==0;
#endif
public void InitData(Dictionary<string, string> userDatas) public void InitData(Dictionary<string, string> userDatas)
{ {

View File

@@ -58,19 +58,9 @@ namespace GameCore
public GuidanceGroupDefine curGroupDefine; public GuidanceGroupDefine curGroupDefine;
public GuidanceDefineSetting guidanceDefineSetting; public GuidanceDefineSetting guidanceDefineSetting;
internal static game.PlayerDataStorage.PlayerKVGuideState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeGuides(raw,errors);
internal void ResetForKVLoad()
{
GuideSaveDataDic = new Dictionary<string,GuideSaveData>(StringComparer.Ordinal);
curSaveData = null; curDefine = null; curGroupDefine = null; popupPanelNode = null; CurPanelName = null;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVGuideState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
GuideSaveDataDic = state.Values.ToDictionary(p=>p.Key,
p=>new GuideSaveData { guideSubIndex=p.Value.Index, isFinish=p.Value.Finished, groupName=p.Value.Group },StringComparer.Ordinal);
}
//待释放 //待释放
IDisposable disposable; IDisposable disposable;
IDisposable disposable2; IDisposable disposable2;
@@ -119,13 +109,7 @@ namespace GameCore
if (GuideSaveDataDic.TryGetValue(item.groupName.ToString(), out _saveData)) if (GuideSaveDataDic.TryGetValue(item.groupName.ToString(), out _saveData))
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if (DeferUnavailablePotionGuide(item, _saveData))
{
keyValuePairs[item.groupName.ToString()] = _saveData;
continue;
}
#endif
if (!_saveData.isFinish && _saveData.guideSubIndex > 0) if (!_saveData.isFinish && _saveData.guideSubIndex > 0)
{ {
for (int i = _saveData.guideSubIndex; i < item.guidanceDefines.Length; i++) for (int i = _saveData.guideSubIndex; i < item.guidanceDefines.Length; i++)
@@ -178,29 +162,7 @@ namespace GameCore
} }
} }
} }
#if UNITY_EDITOR && !SERVICE_HOST
private static bool DeferUnavailablePotionGuide(GuidanceGroupDefine group, GuideSaveData saved)
{
if (!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled || !game.PlayerDataStorage.PlayerKVGameplayLoop.Ready ||
group.groupName != GroupName.EventPotion01 || saved == null || saved.isFinish || saved.guideSubIndex <= 0 ||
group.guidanceDefines == null || saved.guideSubIndex >= group.guidanceDefines.Length ||
group.guidanceDefines[saved.guideSubIndex] == null ||
group.guidanceDefines[saved.guideSubIndex].panelName != "EventPotionPanel")
return false;
try
{
// Existing potion activity classification; availability query does not initialize activity data.
const int potionEventType = 4;
const int potionEventSubType = 5;
return GContext.container.Resolve<FishingEventData>().GetEvent(potionEventType, potionEventSubType) == -1;
}
catch (Exception ex)
{
Debug.LogWarning("Potion guide availability query failed; retaining original guide recovery: " + ex);
return false;
}
}
#endif
public bool GetIsFinish(string groupName) public bool GetIsFinish(string groupName)
{ {
return GuideSaveDataDic.ContainsKey(groupName) && GuideSaveDataDic[groupName].isFinish; return GuideSaveDataDic.ContainsKey(groupName) && GuideSaveDataDic[groupName].isFinish;

View File

@@ -166,22 +166,28 @@ namespace GameCore
} }
if (userService is KVUserService kvUser) if (userService is KVUserService kvUser)
{ {
var store = kvUser.SaveStore; var store = kvUser.SaveStore as KVPlayerSaveStore;
var writtenNames = new HashSet<string>(StringComparer.Ordinal); var writtenNames = new HashSet<string>(StringComparer.Ordinal);
bool accepted = false; var data = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var statistic in Statistics) foreach (var statistic in Statistics)
{ {
if (!writtenNames.Add(statistic.StatisticName)) continue; if (!writtenNames.Add(statistic.StatisticName))
var write = store.Write(statistic.StatisticName, statistic.Value.ToString(CultureInfo.InvariantCulture)); {
if (write.Accepted) accepted = true; continue;
else Debug.LogError("KV statistic write failed: " + statistic.StatisticName + ": " + write.Error); }
if (!KVSnapshot.ValidKey(statistic.StatisticName))
{
Debug.LogError("KV statistic write failed: " + statistic.StatisticName + ": Validation");
continue;
}
data.Add(statistic.StatisticName, statistic.Value.ToString(CultureInfo.InvariantCulture));
} }
if (accepted) if (data.Count > 0)
{ {
var confirmation = await store.FlushAsync(); if (store == null || !await store.SyncDataImmidiate(data))
if (confirmation.Status != PlayerSaveStatus.Confirmed) {
Debug.LogError("KV statistic save not confirmed: " + string.Join(",", writtenNames) + Debug.LogError("KV statistic save failed: " + string.Join(",", data.Keys));
": " + confirmation.Status + ": " + confirmation.Error); }
} }
return; return;
} }
@@ -193,6 +199,35 @@ namespace GameCore
} }
public async Task<GetLeaderboardResult> GetLeadboardData(string leaderboardNaem, int maxCount = 50) public async Task<GetLeaderboardResult> GetLeadboardData(string leaderboardNaem, int maxCount = 50)
{ {
if (userService is KVUserService)
{
var json = await customServerMgr.CustomServerPost("leaderboard/GetLeaderboard",
Newtonsoft.Json.JsonConvert.SerializeObject(new
{
statisticName = leaderboardNaem,
startPosition = 0,
maxResultsCount = maxCount
}));
if (string.IsNullOrEmpty(json))
{
return null;
}
try
{
var response = Newtonsoft.Json.JsonConvert.DeserializeObject<KVLeaderboardResponse>(json);
if (response == null || response.Code != 1 || response.Leaderboard == null ||
response.Leaderboard.Any(entry => entry == null || entry.Profile == null))
{
return null;
}
return new GetLeaderboardResult { Leaderboard = response.Leaderboard };
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogError("KV leaderboard response failed: " + ex.GetType().Name);
return null;
}
}
GetLeaderboardRequest reques = new GetLeaderboardRequest() GetLeaderboardRequest reques = new GetLeaderboardRequest()
{ {
StartPosition = 0, StartPosition = 0,
@@ -209,6 +244,21 @@ namespace GameCore
return await PlayFabClientAsyncAPI.GetLeaderboardAsync(reques); return await PlayFabClientAsyncAPI.GetLeaderboardAsync(reques);
} }
private sealed class KVLeaderboardResponse
{
public int Code
{
get;
set;
}
public List<PlayerLeaderboardEntry> Leaderboard
{
get;
set;
}
}
public async void SetLvData() public async void SetLvData()
{ {
isRefreshing = true; isRefreshing = true;

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using Assets.Scripts.UI.OfferSweep; using Assets.Scripts.UI.OfferSweep;
using cfg; using cfg;
using CodeStage.AntiCheat.ObscuredTypes; using CodeStage.AntiCheat.ObscuredTypes;
@@ -36,41 +36,9 @@ namespace GameCore
Dictionary<string, string> NewPlayerDataDic = new Dictionary<string, string>(); Dictionary<string, string> NewPlayerDataDic = new Dictionary<string, string>();
public IReadOnlyDictionary<string, string> PlayerDataDic => NewPlayerDataDic; public IReadOnlyDictionary<string, string> PlayerDataDic => NewPlayerDataDic;
internal static game.PlayerDataStorage.PlayerKVPlayerState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
IReadOnlyDictionary<string,string> modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodePlayer(raw,modern,snapshot,errors);
internal void ResetForKVLoad()
{
NewPlayerDataDic=new Dictionary<string,string>(StringComparer.Ordinal); vipScoreDic=new Dictionary<int,int>();
newRodList=new List<int>();
lv=vip=vipAllScore=0; vipLastScore=PriceLv=0; priceLevel=null;
energy=pearl=diamond=equipRodID=magnification=seasonId=rewardNewMap=0;
gold=0; goldRM=0; energyRM=0; _currentMapId=_lastMapId=0;
_fishingFrequency=_allFishingFrequency=0; _lastEnergyRecoveryTime=DateTime.MinValue;
IsOpenAppraise=false; InfiniteBuildingData=null;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPlayerState state)
{
if(state==null || state.Core==null) throw new ArgumentNullException(nameof(state));
var modern=state.Modern.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
var scores=state.VipScores.ToDictionary(p=>p.Key,p=>p.Value);
var rods=new List<int>(state.NewRods);
var core=state.Core;
lv=core.Integer(0); gold=core.Unsigned(1); vipAllScore=core.Integer(2); energy=core.Integer(3);
pearl=core.Integer(4); magnification=core.Integer(5); equipRodID=core.Integer(6); _lastEnergyRecoveryTime=core.EnergyTime;
rewardNewMap=core.Integer(8); energyRM=core.Integer(9); goldRM=core.Unsigned(10); diamond=core.Integer(11);
_currentMapId=core.Integer(12); _lastMapId=core.Integer(13); _fishingFrequency=core.Integer(14); _allFishingFrequency=core.Integer(15);
NewPlayerDataDic=modern; vipScoreDic=scores; newRodList=rods;
seasonId=state.Season; IsOpenAppraise=state.Appraise; InfiniteBuildingData=state.Building;
}
#if UNITY_EDITOR
internal game.PlayerDataStorage.PlayerKVPlayerState SnapshotForKVValidation() =>
new game.PlayerDataStorage.PlayerKVPlayerState(new game.PlayerDataStorage.PlayerKVCoreState(new object[] {
(int)lv,(ulong)gold,(int)vipAllScore,(int)energy,(int)pearl,(int)magnification,(int)equipRodID,_lastEnergyRecoveryTime,
(int)rewardNewMap,energyRM,goldRM,(int)diamond,(int)_currentMapId,(int)_lastMapId,_fishingFrequency,_allFishingFrequency,0,0,0
}),NewPlayerDataDic,vipScoreDic,newRodList,seasonId,IsOpenAppraise,InfiniteBuildingData);
internal bool IsDerivedEmptyForKVValidation => (int)vip==0 && vipLastScore==0 && PriceLv==0 && priceLevel==null;
#endif
public ObscuredInt lv { get; set; } public ObscuredInt lv { get; set; }
public ObscuredInt vip { get; private set; } public ObscuredInt vip { get; private set; }
public ObscuredInt vipAllScore { get; private set; } public ObscuredInt vipAllScore { get; private set; }
@@ -1140,57 +1108,6 @@ namespace GameCore
public void SavePlayerData(bool remote = true) public void SavePlayerData(bool remote = true)
{ {
GContext.Publish<PlayerData>(this); GContext.Publish<PlayerData>(this);
SerializeAndSavePlayerData(remote);
}
#if UNITY_EDITOR && !SERVICE_HOST
internal async System.Threading.Tasks.Task ConsumeEnergyForKVLoopAsync(int cost,
game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction action)
{
if(cost<=0 || Energy<cost) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Invalid fishing energy cost or insufficient energy.");
int before=Energy;
var batch=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginFishingWrites(action);
game.PlayerDataStorage.PlayerKVEditResult primary;
try
{
using(batch)
{
ReduceEnergy(cost,false);
GContext.Publish(new ConditionTypeEvent(ConditionType.ConsumeEnergy,cost));
primary=SerializeAndSavePlayerData(true);
}
game.PlayerDataStorage.PlayerKVGameplayLoop.Audit("fishing-energy-applied",new{before,cost,after=(int)Energy,writes=batch.Records.Count,localOnly=batch.Records.Count(r=>!r.Remote)});
await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmFishingAsync(action,batch,primary);
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); throw; }
}
internal async System.Threading.Tasks.Task SetEquipRodIDForKVLoopAsync(int id)
{
var batch=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginEquip();
game.PlayerDataStorage.PlayerKVEditResult primary;
try
{
using(batch)
{
equipRodID=id;
GContext.container.Resolve<PlayerFishData>().SetRedPoint();
GContext.Publish<PlayerData>(this);
primary=SerializeAndSavePlayerData(true);
}
await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmEquipAsync(batch,primary);
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); throw; }
}
#endif
#if UNITY_EDITOR && !SERVICE_HOST
internal game.PlayerDataStorage.PlayerKVEditResult SaveFishingRewardForKVLoop()
{
game.PlayerDataStorage.PlayerKVGameplayLoop.CheckRewardMainSave();
return SerializeAndSavePlayerData(true);
}
#endif
private game.PlayerDataStorage.PlayerKVEditResult SerializeAndSavePlayerData(bool remote)
{
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.Append(lv).Append(","); sb.Append(lv).Append(",");
sb.Append(gold).Append(","); sb.Append(gold).Append(",");
@@ -1231,11 +1148,7 @@ namespace GameCore
NewPlayerDataDic["ct"] = GContext.container.Resolve<FishingEventData>().CTSerialize(); NewPlayerDataDic["ct"] = GContext.container.Resolve<FishingEventData>().CTSerialize();
NewPlayerDataDic["rt"] = GContext.container.Resolve<FishingEventData>().RTSerialize(); ; NewPlayerDataDic["rt"] = GContext.container.Resolve<FishingEventData>().RTSerialize(); ;
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return game.PlayerDataStorage.PlayerKVGameplayLoop.Write("NewPlayerDataDic",Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic),remote);
#endif
playerSaveStore.Write("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote).RequireAccepted(); playerSaveStore.Write("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote).RequireAccepted();
return null;
} }
public void Init() public void Init()
{ {

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Linq; using System.Linq;
using UnityEngine; using UnityEngine;
using cfg; using cfg;
@@ -86,45 +86,9 @@ namespace GameCore
public Dictionary<string, float> LuckDataDic = new Dictionary<string, float>(); public Dictionary<string, float> LuckDataDic = new Dictionary<string, float>();
public bool IsOpenPiercing = false; public bool IsOpenPiercing = false;
public int RodSelectedInPack = 0; public int RodSelectedInPack = 0;
internal static game.PlayerDataStorage.PlayerKVFishState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
IReadOnlyDictionary<string,string> modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFish(raw,modern,snapshot,errors);
internal void ResetForKVLoad()
{
mapFishData=new Dictionary<int,Dictionary<int,FishWeightData>>(); fishData=new Dictionary<int,FishWeightData>(); fishCardData=new Dictionary<int,int>();
mapProgress=new Dictionary<int,int>(); rodData=new Dictionary<int,int>(); rodLevelData=new Dictionary<int,int>();
fishSwimToMap=new Dictionary<int,int>(); fishSwimToMapCount=new Dictionary<int,int>(); _honorlevel=new Dictionary<int,int>();
LuckDataDic=new Dictionary<string,float>(StringComparer.Ordinal); rodAccessoriesData=null;
InitBotLuck=0; InitFishLuck=0; BotCount=0; _isRedPoint_up=false; RodCanUp=false; RodCanUpLevel=false;
MapTipforUnlocked=0; IsOpenMap=false; IsOpenRod=false; IsOpenPiercing=false; RodSelectedInPack=0;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVFishState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
var maps=state.Maps.ToDictionary(m=>m.Key,m=>m.Value.ToDictionary(p=>p.Key,p=>new FishWeightData {
Level=p.Value.Level,MaxG=p.Value.Weight,TotalCard=p.Value.Cards,TotalNum=p.Value.Number,NewFish=p.Value.New }));
var flat=new Dictionary<int,FishWeightData>();
foreach(var map in maps.Values) foreach(var p in map) flat.Add(p.Key,p.Value);
mapFishData=maps; fishData=flat; fishCardData=state.Cards.ToDictionary(p=>p.Key,p=>p.Value);
rodData=state.Rods.ToDictionary(p=>p.Key,p=>p.Value); rodLevelData=state.RodLevels.ToDictionary(p=>p.Key,p=>p.Value);
fishSwimToMap=state.Swim.ToDictionary(p=>p.Key,p=>p.Value); fishSwimToMapCount=state.SwimCount.ToDictionary(p=>p.Key,p=>p.Value);
mapProgress=state.Progress.ToDictionary(p=>p.Key,p=>p.Value); _honorlevel=state.Honor.ToDictionary(p=>p.Key,p=>p.Value);
LuckDataDic=state.Luck.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
var a=state.Accessories;
rodAccessoriesData=a==null ? null : new RodGSAData { GloveID=a.Glove,RodToSkin=a.RodSkin.ToDictionary(p=>p.Key,p=>p.Value),
GloveIds=new List<int>(a.Gloves),SkinIds=new List<int>(a.Skins),GloveNewIds=new List<int>(a.NewGloves),SkinNewIds=new List<int>(a.NewSkins) };
}
#if UNITY_EDITOR
internal FishWeightData FishForKVValidation(int map,int id) => mapFishData[map][id];
internal bool IndexesMatchForKVValidation(int map,int id,int card) => ReferenceEquals(mapFishData[map][id],fishData[id]) && fishCardData[card]==fishData[id].TotalCard;
internal bool EmptyForKVValidation => mapFishData.Count==0 && fishData.Count==0 && fishCardData.Count==0 && mapProgress.Count==0 && rodData.Count==0 && rodLevelData.Count==0 &&
fishSwimToMap.Count==0 && fishSwimToMapCount.Count==0 && _honorlevel.Count==0 && LuckDataDic.Count==0 && rodAccessoriesData==null &&
InitBotLuck==0 && InitFishLuck==0 && BotCount==0 && !_isRedPoint_up && !RodCanUp && !RodCanUpLevel && !IsOpenMap && !IsOpenRod && !IsOpenPiercing && MapTipforUnlocked==0 && RodSelectedInPack==0;
internal bool DictionariesMatchForKVValidation(game.PlayerDataStorage.PlayerKVFishState s) =>
rodData.OrderBy(p=>p.Key).SequenceEqual(s.Rods.OrderBy(p=>p.Key)) && rodLevelData.OrderBy(p=>p.Key).SequenceEqual(s.RodLevels.OrderBy(p=>p.Key)) &&
fishSwimToMap.OrderBy(p=>p.Key).SequenceEqual(s.Swim.OrderBy(p=>p.Key)) && fishSwimToMapCount.OrderBy(p=>p.Key).SequenceEqual(s.SwimCount.OrderBy(p=>p.Key)) &&
mapProgress.OrderBy(p=>p.Key).SequenceEqual(s.Progress.OrderBy(p=>p.Key)) && _honorlevel.OrderBy(p=>p.Key).SequenceEqual(s.Honor.OrderBy(p=>p.Key));
#endif
public void InitData(Dictionary<string, string> userDatas) public void InitData(Dictionary<string, string> userDatas)
{ {
@@ -1195,14 +1159,6 @@ namespace GameCore
SetFishData(id, fishData[id]); SetFishData(id, fishData[id]);
} }
#if UNITY_EDITOR && !SERVICE_HOST
internal void SaveCaughtFishForKVLoop(int fishId)
{
if(!fishData.TryGetValue(fishId,out var data)) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Caught fish data is missing.");
game.PlayerDataStorage.PlayerKVGameplayLoop.CheckCaughtFishSave(fishId,data.TotalNum);
SetFishData(fishId,data);
}
#endif
void SetFishData(int id, FishWeightData data) void SetFishData(int id, FishWeightData data)
{ {
int mapID = _tables.TbFishData.GetOrDefault(id).AreaID; int mapID = _tables.TbFishData.GetOrDefault(id).AreaID;

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using CodeStage.AntiCheat.ObscuredTypes; using CodeStage.AntiCheat.ObscuredTypes;
using game; using game;
@@ -133,43 +133,10 @@ namespace GameCore
Dictionary<int, int> advertCount = new Dictionary<int, int>(); Dictionary<int, int> advertCount = new Dictionary<int, int>();
#endregion #endregion
internal static game.PlayerDataStorage.PlayerKVShopState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeShop(raw,snapshot,errors);
internal void ResetForKVLoad()
{
shopPackDailyBuyCount=new Dictionary<string,int>(StringComparer.Ordinal); shopAllPackBuyCount=new Dictionary<int,int>();
shopGiftBuyCount=new Dictionary<int,int>(); buyData=new List<float>(); advertCount=new Dictionary<int,int>();
Voucher=0; ChainPackData=null; VIPAdPackData=null; TurntableAdData=null;
_diamondShopItemRecords=new Dictionary<int,ItemShopRecord>(); _diamondShopRefreshCount=0; _diamondShopRefreshTime=default;
CouponData.ResetForKVLoad();
}
static DailySignEvent KVDaily(game.PlayerDataStorage.PlayerKVDailyValue value) => value==null ? null :
new DailySignEvent { ID=value.Id,index=value.Index,lastID=value.LastId,time=value.Time };
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVShopState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
shopPackDailyBuyCount=state.Daily.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
shopAllPackBuyCount=state.All.ToDictionary(p=>p.Key,p=>p.Value); shopGiftBuyCount=state.Gift.ToDictionary(p=>p.Key,p=>p.Value);
advertCount=state.Advert.ToDictionary(p=>p.Key,p=>p.Value); buyData=new List<float>(state.Buy); Voucher=state.Voucher;
ChainPackData=KVDaily(state.Chain); TurntableAdData=KVDaily(state.Turntable);
VIPAdPackData=state.Vip==null ? null : new AdvertData { ID=state.Vip.Id,index=state.Vip.Index,day=state.Vip.Day,cdTime=state.Vip.Time,isOpen=state.Vip.Open };
_diamondShopItemRecords=state.Items.ToDictionary(p=>p.Key,p=>new ItemShopRecord(p.Value.Id,p.Value.Discount,p.Value.Bought));
_diamondShopRefreshCount=state.RefreshCount; _diamondShopRefreshTime=state.RefreshTime; CouponData.ReplaceFromKVDecoded(state.Coupon);
}
#if UNITY_EDITOR
internal game.PlayerDataStorage.PlayerKVShopState SnapshotForKVValidation()
{
game.PlayerDataStorage.PlayerKVDailyValue Daily(DailySignEvent v) => v==null ? null : new game.PlayerDataStorage.PlayerKVDailyValue(v.ID,v.index,v.lastID,v.time);
return new game.PlayerDataStorage.PlayerKVShopState(shopPackDailyBuyCount,shopAllPackBuyCount,shopGiftBuyCount,advertCount,
_diamondShopItemRecords.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVShopItemValue(p.Value.ItemID,p.Value.Discount,p.Value.hasBought)),
buyData,Voucher,_diamondShopRefreshCount,_diamondShopRefreshTime,Daily(ChainPackData),Daily(TurntableAdData),
VIPAdPackData==null ? null : new game.PlayerDataStorage.PlayerKVAdvertValue(VIPAdPackData.ID,VIPAdPackData.index,VIPAdPackData.day,VIPAdPackData.cdTime,VIPAdPackData.isOpen),
new game.PlayerDataStorage.PlayerKVCouponState(CouponData.ActivateTime,CouponData.EventId,CouponData.RedirectId,CouponData.IsBought));
}
internal void MutateCollectionsForKVValidation()
{ buyData.Clear(); advertCount.Clear(); _diamondShopItemRecords.Values.First().Discount=99; }
#endif
public void InitData(Dictionary<string, string> userDatas) public void InitData(Dictionary<string, string> userDatas)
{ {

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using game; using game;
using LitJson; using LitJson;
@@ -39,18 +39,9 @@ namespace GameCore
MonthlySignEvent _monthlySignEvent = null; MonthlySignEvent _monthlySignEvent = null;
internal static game.PlayerDataStorage.PlayerKVMonthlySignState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeMonthlySign(raw,errors);
internal void ResetForKVLoad() { _monthlySignEvent = null; }
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVMonthlySignState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
_monthlySignEvent = !state.IsPresent ? null : new MonthlySignEvent
{
index = state.Index,
dailySignEvent = new DailySignEvent { ID=state.DailyId, index=state.DailyIndex, lastID=state.LastId, time=state.Time }
};
}
public DailySignEvent dailySignEvent public DailySignEvent dailySignEvent
{ {

View File

@@ -48,25 +48,9 @@ namespace GameCore
public int FishHuntGameCount => _fishHuntGameCount; public int FishHuntGameCount => _fishHuntGameCount;
public int BombGameCount => _bombGameCount; public int BombGameCount => _bombGameCount;
internal void ResetForKVLoad()
{
_heistGameCount=_bombGameCount=_fishHuntGameCount=0;
opponentItemDatas=new List<OpponentItemData>(); opponentFriendItemDatas=new List<OpponentItemData>();
opponentNotFriendItemDatas=new List<OpponentItemData>(); opponentFriend=new List<OpponentItemData>();
opponentFriendDic=new Dictionary<string,OpponentItemData>(); opponentEnemy=new Dictionary<EnemyType,List<OpponentItemData>>();
opponentItemDatasPre=new List<OpponentItemData>(); opponentFriendItemDatasPre=new List<OpponentItemData>();
opponentNotFriendItemDatasPre=new List<OpponentItemData>(); powerData=new Dictionary<string,float>();
opponentData=null; nextOpponent=null; curEnemyType=EnemyType.Aquarium; RobotMapId=nextRobotMapId=0;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCoreState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
_heistGameCount=state.Integer(16); _bombGameCount=state.Integer(17); _fishHuntGameCount=state.Integer(18);
}
internal bool IsOpponentEmptyForKVValidation => opponentItemDatas.Count==0 && opponentFriendItemDatas.Count==0 &&
opponentNotFriendItemDatas.Count==0 && opponentFriend.Count==0 && opponentFriendDic.Count==0 && opponentEnemy.Count==0 &&
opponentItemDatasPre.Count==0 && opponentFriendItemDatasPre.Count==0 && opponentNotFriendItemDatasPre.Count==0 &&
powerData.Count==0 && opponentData==null && nextOpponent==null && RobotMapId==0 && nextRobotMapId==0;
//public UnityEngine.Vector3 image_extra_pos { set; get; } //public UnityEngine.Vector3 image_extra_pos { set; get; }

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using game; using game;
using LitJson; using LitJson;
@@ -55,28 +55,9 @@ namespace GameCore
//VIP 等级叠加 //VIP 等级叠加
public Dictionary<string, GiftVIPDData> giftVIPDData = new Dictionary<string, GiftVIPDData>(); public Dictionary<string, GiftVIPDData> giftVIPDData = new Dictionary<string, GiftVIPDData>();
internal static game.PlayerDataStorage.PlayerKVTriggerState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeTriggers(raw,errors);
internal void ResetForKVLoad()
{
triggerPackList=new List<TriggerPackBuyData>();
giftVIPDData=new Dictionary<string,GiftVIPDData>(StringComparer.Ordinal);
fixedTimeShopDic=new Dictionary<int,FixedTimeEvent>();
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVTriggerState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
var packs=state.Packs.Select(p=>new TriggerPackBuyData {
ID=p.ID,index=p.index,lastID=p.lastID,vipLevel=p.vipLevel,mapID=p.mapID,time=p.time }).ToList();
var gifts=state.Gifts.ToDictionary(p=>p.Key,p=>new GiftVIPDData {
oldVipLevel=p.Value.oldVipLevel,curVipLevel=p.Value.curVipLevel,timer=p.Value.timer },StringComparer.Ordinal);
var fixedValues=state.Fixed.ToDictionary(p=>p.Key,p=>new FixedTimeEvent { id=p.Value.id,endTime=p.Value.endTime });
triggerPackList=packs; giftVIPDData=gifts; fixedTimeShopDic=fixedValues;
}
#if UNITY_EDITOR
internal FixedTimeEvent FixedForKVValidation(int key) => fixedTimeShopDic.TryGetValue(key,out var value) ? value : null;
internal bool IsEmptyForKVValidation => triggerPackList.Count==0 && giftVIPDData.Count==0 && fixedTimeShopDic.Count==0;
#endif
public void Init() public void Init()
{ {
playerShopData = GContext.container.Resolve<PlayerShopData>(); playerShopData = GContext.container.Resolve<PlayerShopData>();

View File

@@ -21,18 +21,10 @@ public class EventPackData
} }
private Data _data; private Data _data;
internal static game.PlayerDataStorage.PlayerKVEventPackState DecodeForKVLoad(System.Collections.Generic.IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeEventPack(raw,snapshot,errors);
internal void ResetForKVLoad() { _data=default(Data); }
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEventPackState state)
{
if (state == null) throw new System.ArgumentNullException(nameof(state));
_data=new Data { currentEventID=state.EventId,purchaseCount=state.PurchaseCount,
VIPLvlWhenEventActivate=state.VipLevel,redirectID=state.RedirectId };
}
internal game.PlayerDataStorage.PlayerKVEventPackState SnapshotForKVValidation() =>
new game.PlayerDataStorage.PlayerKVEventPackState(_data.currentEventID,_data.purchaseCount,_data.VIPLvlWhenEventActivate,_data.redirectID);
public int CurrentEventID { get { return _data.currentEventID; } } public int CurrentEventID { get { return _data.currentEventID; } }
public int PurchaseCount { get { return _data.purchaseCount; } } public int PurchaseCount { get { return _data.purchaseCount; } }
public int VIPLvlWhenEventActivated { get { return _data.VIPLvlWhenEventActivate; } } public int VIPLvlWhenEventActivated { get { return _data.VIPLvlWhenEventActivate; } }

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -104,51 +104,10 @@ namespace DataCenter
private const bool EnableTraceLogs = false; private const bool EnableTraceLogs = false;
public const string SaveKey = "EventPartnerGatherData"; public const string SaveKey = "EventPartnerGatherData";
internal static game.PlayerDataStorage.PlayerKVPartnerState DecodeForKVLoad(
IReadOnlyDictionary<string,string> raw,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodePartner(raw,snapshot,errors);
internal void ResetForKVLoad()
{
// Only a fresh controlled container is admitted. Never detach active gameplay work.
if(_isInit || _act!=null || _disposables!=null || _autoMatchStatusInFlight!=null || PartnerInfoManager.HasSubscriptionsForKVLoad)
throw new InvalidOperationException("Active partner module cannot enter KV load.");
var partner=new PartnerInfoManager();
var robot=new EventPartnerGatherRobotInvitationSystem();
GatherData=null; _isEventDataChanged=false; CurrentBuildPartner=null;
PartnerInfoManager=partner; RobotInvitationSystem=robot;
_fishingEvent=null; EventPartnerMineMainData=null; EventPartnerMineSpinPointList=null; EventPartnerMineInitData=null;
_nodeCumulativeSteps.Clear(); _nodeSteps.Clear(); _stageDropList.Clear(); StartTime=default; EndTime=default;
_isInit=false; _spinId=-1; _userGm=false; BonusPercent=0; BonusExpireDuration=0;
_dateTimeForGetEvent=default; startTime=0; delta=0; _lastAutoMatchStatusUtc=default; _cachedAutoMatchStatus=null;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPartnerState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
if(!state.Present) { GatherData=null; return; }
GatherData=new EventPartnerGatherData
{
FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime,
CurrentPoints=state.CurrentPoints,CurrentMiningProgress=state.CurrentMiningProgress,Token=state.Token,
InvitedCount=state.InvitedCount,TicketCount=state.TicketCount,
CompletedNodesList=state.CompletedNodesList.Select(n=>new List<int>(n)).ToList(),
BuilderPartnerScore=new List<(int,int)>(state.BuilderPartnerScore),PartnerBonus=(state.BonusPercent,state.BonusExpireUtc),
FreeSpinNum=state.FreeSpinNum,Bots=state.Bots.ToDictionary(p=>p.Key,p=>new EventBuildBotInfo
{ Id=p.Value.Id,ScorePlayer=p.Value.ScorePlayer,ScoreBot=p.Value.ScoreBot,Grade=p.Value.Grade,
TargetScore=p.Value.TargetScore,WakeStartTime=p.Value.WakeStartTime,WakingSeconds=p.Value.WakingSeconds }),
IsRecvFinalReward=state.IsRecvFinalReward,UIFinishedPartners=new List<bool>(state.UIFinishedPartners),
IsFinished=state.IsFinished,SpinState=(ESpinState)state.SpinState,InflationRate=state.InflationRate,
IsEnableAutoMatch=state.IsEnableAutoMatch,LastAutoMatchTime=state.LastAutoMatchTime
};
}
#if UNITY_EDITOR
internal bool RuntimeEmptyForKVValidation => !_isInit && !_isEventDataChanged && _act==null && _disposables==null &&
_autoMatchStatusInFlight==null && _cachedAutoMatchStatus==null && _fishingEvent==null && CurrentBuildPartner==null &&
EventPartnerMineMainData==null && EventPartnerMineSpinPointList==null && EventPartnerMineInitData==null &&
_nodeCumulativeSteps.Count==0 && _nodeSteps.Count==0 && _stageDropList.Count==0 && StartTime==default && EndTime==default &&
_spinId==-1 && !_userGm && BonusPercent==0 && BonusExpireDuration==0 && _dateTimeForGetEvent==default &&
startTime==0 && delta==0 && _lastAutoMatchStatusUtc==default && !PartnerInfoManager.HasSubscriptionsForKVLoad;
#endif
[Inject] public Tables _tables { get; set; } [Inject] public Tables _tables { get; set; }
[Inject] public EventPartnerService EventPartnerService { get; set; } [Inject] public EventPartnerService EventPartnerService { get; set; }

View File

@@ -450,38 +450,11 @@ namespace game
#region 数据相关 #region 数据相关
private EventPinballData m_PinballData; private EventPinballData m_PinballData;
internal bool IsInactiveForKVValidation => m_Tables==null && m_TbEventPinballMain==null && m_TbEventPinballPoint==null &&
m_TbEventPinballReward==null && m_UIPanel==null && m_InfoPanel==null && m_RewardList.Count==0 &&
m_PointWeightList.Count==0 && !m_IsSimulatorMode && !m_IsMultipleMax && m_CumulativeResponseNumber==0 &&
m_AccumulatedTriggerCount==0 && m_TemporaryScore==0 && m_TemporaryConsumedToken==0 && m_MagnificationListIndex==0 && m_BonusResponseNumber==10;
internal static game.PlayerDataStorage.PlayerKVPinballState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodePinball(raw,snapshot,errors);
internal void ResetForKVLoad()
{
m_PinballData=null; m_BonusResponseNumber=10; m_CumulativeResponseNumber=m_AccumulatedTriggerCount=m_TemporaryScore=m_TemporaryConsumedToken=m_MagnificationListIndex=0;
m_IsMultipleMax=false; m_IsSimulatorMode=false; m_RewardList=new List<float>(); m_PointWeightList=new List<float>(); m_UIPanel=m_InfoPanel=null;
m_Tables=null; m_TbEventPinballMain=null; m_TbEventPinballPoint=null; m_TbEventPinballReward=null;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPinballState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
m_PinballData=!state.IsPresent ? null : new EventPinballData
{
EventID=state.EventID,
PinballMainID=state.PinballMainID,
SpinPoint=state.SpinPoint,
Scores=state.Scores,
Multiple=state.Multiple,
Token=state.Token,
ConsumedToken=state.ConsumedToken,
RewardIndex=state.RewardIndex,
Force=state.Force,
LuckyValue=state.LuckyValue,
InflationRate=state.InflationRate
};
}
public EventPinballData PinballData { get { return m_PinballData; } } public EventPinballData PinballData { get { return m_PinballData; } }
public int RewardMaxIndex public int RewardMaxIndex
{ {

View File

@@ -127,54 +127,11 @@ namespace game
#region 数据相关 #region 数据相关
private EventPotionData m_PotionData; private EventPotionData m_PotionData;
internal static PlayerDataStorage.PlayerKVPotionState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => PlayerDataStorage.PlayerKVSaveSchema.DecodePotion(raw,errors);
internal void ResetForKVLoad()
{
m_PotionData=null; m_PendingFirstPlayedEventId=0; m_SpriteAtlas=null; m_Tables=null;
m_TbEventPotionMain=null; m_TbEventPotionStage=null; m_TbEventPotionBlock=null; m_TbEventPotionBottle=null;
m_PotionResDict=new Dictionary<int,EventPotionResource>(); m_WallsBefore=new List<PotionItemData>(); m_WallsAfter=new List<PotionItemData>();
m_UIPanel=m_InfoPanel=m_RewardPopupPanel=null; m_ScrollerDataList=new List<PotionItemData>();
}
static PotionItemData MaterializeKVPotionItem(PlayerDataStorage.PlayerKVPotionItem value) => new PotionItemData {
name=value.Name,index=value.Index,isOver=value.IsOver,DataType=(EventPotionDataType)value.DataType,
ColorTypeList=new List<int>(value.Colors),ShelfTypeList=value.Shelves.Select(n=>(EventPotionShelfType)n).ToList(),
GridStateList=value.Grids.Select(n=>(EventPotionGridState)n).ToList(),FromDisplayAreaIndexList=new List<int>(value.FromDisplay)
};
internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVPotionState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
m_PotionData=!state.IsPresent ? null : new EventPotionData {
EventID=state.EventID,
PotionID=state.PotionID,
StageID=state.StageID,
Stage1TotalLevel=state.Stage1TotalLevel,
Stage2TotalLevel=state.Stage2TotalLevel,
LevelID=state.LevelID,
LevelIndex=state.LevelIndex,
CorrectNum=state.CorrectNum,
SubmitNum=state.SubmitNum,
Token=state.Token,
ConsumedToken=state.ConsumedToken,
Rule=state.Rule,
PreEventID=state.PreEventID,
PrePotionID=state.PrePotionID,
PreStageID=state.PreStageID,
PreLevelID=state.PreLevelID,
Round=state.Round,
FirstPlayedEventId=state.FirstPlayedEventId,
IsOver=state.IsOver,
Name=state.Name,
InflationRate=state.InflationRate,
LevelTargetData=MaterializeKVPotionItem(state.LevelTargetData),
LevelDisplayData=MaterializeKVPotionItem(state.LevelDisplayData),
LevelProcessedList=state.LevelProcessedList.Select(MaterializeKVPotionItem).ToList()
};
}
internal bool IsInactiveForKVValidation => m_PendingFirstPlayedEventId==0 && m_SpriteAtlas==null && m_Tables==null &&
m_TbEventPotionMain==null && m_TbEventPotionStage==null && m_TbEventPotionBlock==null && m_TbEventPotionBottle==null &&
m_PotionResDict.Count==0 && m_WallsBefore.Count==0 && m_WallsAfter.Count==0 && m_ScrollerDataList.Count==0 &&
m_UIPanel==null && m_InfoPanel==null && m_RewardPopupPanel==null;
public EventPotionData PotionData { get { return m_PotionData; } } public EventPotionData PotionData { get { return m_PotionData; } }
public void InitLevel() public void InitLevel()

View File

@@ -95,45 +95,11 @@ namespace game
#region 数据相关 #region 数据相关
private EventScratchTicketData m_ScratchTicketData; private EventScratchTicketData m_ScratchTicketData;
internal bool IsInactiveForKVValidation => m_Tables==null && m_TbEventScratchMain==null && m_TbEventScratchCard==null &&
m_TbEventScratchReward==null && m_ChainPackData==null && m_NormalPackData==null && m_SpriteAtlas==null && m_SpriteDict==null &&
m_UIPanel==null && m_InfoPanel==null && m_RewardPanel==null && m_ChainPackPanel==null && m_PackPanel==null &&
m_RecordList.Count==0 && m_PointWeightList.Count==0 && m_ScratchStampList.Count==0 &&
TempRecordRewardTaskID==0 && TempRecordRewardTaskConsumedToken==0;
internal static game.PlayerDataStorage.PlayerKVScratchState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeScratch(raw,snapshot,errors);
internal void ResetForKVLoad()
{
m_ScratchTicketData=null; TempRecordRewardTaskID=TempRecordRewardTaskConsumedToken=0; m_RecordList=new List<EventScratchTicketRecordData>();
m_PointWeightList=new List<float>(); m_ScratchStampList=new List<int>(); m_ChainPackData=null; m_NormalPackData=null;
m_UIPanel=m_InfoPanel=m_RewardPanel=m_ChainPackPanel=m_PackPanel=null; m_SpriteAtlas=null; m_SpriteDict=null;
m_Tables=null; m_TbEventScratchMain=null; m_TbEventScratchCard=null; m_TbEventScratchReward=null;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVScratchState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
m_ScratchTicketData=!state.IsPresent ? null : new EventScratchTicketData
{
EventID=state.EventID,
ScratchMainID=state.ScratchMainID,
ChainPackID=state.ChainPackID,
Token=state.Token,
ConsumedToken=state.ConsumedToken,
RewardTaskConsumedToken=state.RewardTaskConsumedToken,
EventScratchRewardTaskID=state.EventScratchRewardTaskID,
EventScratchCardID=state.EventScratchCardID,
TotalConsumedToken=state.TotalConsumedToken,
ChainPackProgress=state.ChainPackProgress,
magIndex=state.magIndex,
IsOver=state.IsOver,
LuckyValue=state.LuckyValue,
InflationRate=state.InflationRate,
StampRewardList=new List<int>(state.StampRewardList),
StampRewardDict=state.StampRewardDict.ToDictionary(p=>p.Key,p=>new EventScratchTicketOwnedStampRewardData { EventScratchCardID=p.Value.EventScratchCardID,Count=p.Value.Count })
};
}
public EventScratchTicketData ScratchTicketData { get { return m_ScratchTicketData; } } public EventScratchTicketData ScratchTicketData { get { return m_ScratchTicketData; } }
public EventScratchMain GetFreshScratchTicketData() public EventScratchMain GetFreshScratchTicketData()
{ {

View File

@@ -13,19 +13,9 @@ public class FishingBoxData
public Dictionary<int, int> FishingNewBoxs = new(); public Dictionary<int, int> FishingNewBoxs = new();
public Dictionary<int, int> FishingBoxOpenNum = new(); public Dictionary<int, int> FishingBoxOpenNum = new();
internal static game.PlayerDataStorage.PlayerKVIntMapState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
IReadOnlyDictionary<string,string> modern, game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFishingBox(raw,modern,errors);
internal void ResetForKVLoad()
{
FishingNewBoxs = new Dictionary<int,int>();
FishingBoxOpenNum = new Dictionary<int,int>();
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVIntMapState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
FishingBoxOpenNum = state.Values.ToDictionary(p=>p.Key,p=>p.Value);
}
public void InitData(string data) public void InitData(string data)
{ {

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using game; using game;
using GameCore; using GameCore;
@@ -63,44 +63,11 @@ public class FishingChallengeManager
private DateTime EndTime; private DateTime EndTime;
// //
public FishingChallengeData fishingChallengeData { get; private set; } public FishingChallengeData fishingChallengeData { get; private set; }
internal bool IsInactiveForKVValidation => !IsInit && CurFishingEventData==null && eventChallengeMain==null &&
eventChallengeConfig==null && challengeMapData==null && _stageDropList.Count==0 && _eliminateListScore.Count==0;
internal static game.PlayerDataStorage.PlayerKVChallengeState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeChallenge(raw,snapshot,errors);
internal void ResetForKVLoad()
{
fishingChallengeData=null; IsInit=false; PrefabName=null;
#if UNITY_EDITOR
_cheatingScore=0;
#endif
TopRankTier=Rank=FullStep=PlayerFullNumber=PreScore=0;
CurFishingEventData=null; StartTime=EndTime=default; eventChallengeMain=null; eventChallengeConfig=null; challengeMapData=null; itemDataList=null;
_stageDropList.Clear(); _eliminateListScore.Clear(); _eventTrackStageList=new List<int>(); _eventTrackNormalRewardList=new List<int>(); InVisibleReason=string.Empty;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVChallengeState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
fishingChallengeData=!state.IsPresent || state.IsExplicitNull ? null : new FishingChallengeData
{
FirstOpen=state.FirstOpen,
EventId=state.EventId,
RankTierLevel=state.RankTierLevel,
Finished=state.Finished,
RedirectID=state.RedirectID,
Score=state.Score,
ProcessedScore=state.ProcessedScore,
step=state.step,
uiStep=state.uiStep,
index=state.index,
StartTime=state.StartTime,
EndTime=state.EndTime,
eliminateNumber=new List<int>(state.eliminateNumber),
robotAccountList=new List<int>(state.robotAccountList),
InflationRate=state.InflationRate
};
}
public EventChallengeMain eventChallengeMain { get; private set; } public EventChallengeMain eventChallengeMain { get; private set; }
public EventChallengeConfig eventChallengeConfig { get; private set; } public EventChallengeConfig eventChallengeConfig { get; private set; }
public MapData challengeMapData { get; private set; } public MapData challengeMapData { get; private set; }

View File

@@ -53,27 +53,10 @@ namespace DataCenter
// 当前可保存数据 // 当前可保存数据
public TOfferChainsData OfferChainsData { get; private set; } public TOfferChainsData OfferChainsData { get; private set; }
internal static game.PlayerDataStorage.PlayerKVOfferState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeOffer(raw,false,snapshot,errors);
internal void ResetForKVLoad()
{
OfferChainsData=null; ValidIds=new List<int>(); _fishingEvent=null; _isInit=false;
OfferChainsChestMain=null; OfferChainsChestRewards=null;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVOfferState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
OfferChainsData=!state.IsPresent ? null : new TOfferChainsData
{
FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime,
ItemId=state.ItemId,LotteryNum=state.LotteryNum,
Items=state.Items.Select(item=>new TOfferChainsData.TItem {
Id=item.Id,Item=item.Item,Number=item.Number,Status=item.Status }).ToList(),
RewardList=new List<int>(state.RewardList),Finished=state.Finished,ChestReward=state.ChestReward
};
}
internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent == null;
public List<int> ValidIds; public List<int> ValidIds;
public bool IsFirstOpen public bool IsFirstOpen

View File

@@ -62,27 +62,10 @@ namespace DataCenter
public TOfferDiscoPartyData OfferDiscoPartyData { get; private set; } public TOfferDiscoPartyData OfferDiscoPartyData { get; private set; }
internal static game.PlayerDataStorage.PlayerKVOfferState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeOffer(raw,true,snapshot,errors);
internal void ResetForKVLoad()
{
OfferDiscoPartyData=null; ValidIds=new List<int>(); _fishingEvent=null; _isInit=false;
OfferDiscoParty=null; OfferDiscoPartyRewards=null; FreeTime=1;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVOfferState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
OfferDiscoPartyData=!state.IsPresent ? null : new TOfferDiscoPartyData
{
FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime,
ItemId=state.ItemId,LotteryNum=state.LotteryNum,
Items=state.Items.Select(item=>new TOfferDiscoPartyData.TItem {
Id=item.Id,Item=item.Item,Number=item.Number,Status=(TItemStatues) item.Status }).ToList(),
RewardList=new List<int>(state.RewardList)
};
}
internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent == null;
public List<int> ValidIds; // public List<int> ValidIds; //

View File

@@ -36,38 +36,13 @@ public class PiggyBankPackData
} }
private Data _data; private Data _data;
private bool _kvLoadPresent;
internal bool KVLoadPresent => _kvLoadPresent;
internal static game.PlayerDataStorage.PlayerKVPiggyBankState DecodeForKVLoad(
IReadOnlyDictionary<string, string> raw, game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodePiggyBank(raw, snapshot, errors);
internal void ResetForKVLoad()
{
_kvLoadPresent = false;
_data = default(Data);
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPiggyBankState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
if (!state.IsPresent) { ResetForKVLoad(); return; }
var replacement = new Data
{
currentEventID = state.currentEventID,
purchaseCount = state.purchaseCount,
VIPLvlWhenEventActivate = state.VIPLvlWhenEventActivate,
savingProgress = state.savingProgress,
redirectID = state.redirectID,
doNeedTrigger = state.doNeedTrigger,
isTriggered = state.isTriggered,
inflation = state.inflation,
};
_data = replacement;
_kvLoadPresent = true;
}
internal game.PlayerDataStorage.PlayerKVPiggyBankState SnapshotForKVValidation() =>
new game.PlayerDataStorage.PlayerKVPiggyBankState(_kvLoadPresent, _data.currentEventID, _data.purchaseCount, _data.VIPLvlWhenEventActivate, _data.savingProgress, _data.redirectID, _data.doNeedTrigger, _data.isTriggered, _data.inflation);
public bool IsWithinEventTime public bool IsWithinEventTime
{ {
get get

View File

@@ -473,20 +473,28 @@ public class DebugFunc
private static void LoginTime(int hours) private static void LoginTime(int hours)
{ {
var userService = GContext.container.Resolve<game.IUserService>() as game.PlayFabUserService; var userService = GContext.container.Resolve<game.IUserService>();
if (userService == null) return; if (userService is not game.PlayFabUserService && userService is not game.KVUserService)
{
return;
}
var loginTime = userService.LoginTime; var loginTime = userService.LoginTime;
var newLoginTime = loginTime.AddHours(hours); var newLoginTime = loginTime.AddHours(hours);
var prop = userService.GetType().GetProperty("LoginTime"); var prop = userService is game.KVUserService
? typeof(game.KVUserService).GetProperty("LoginTime")
: userService.GetType().GetProperty("LoginTime");
prop.SetValue(userService, newLoginTime); prop.SetValue(userService, newLoginTime);
Debug.Log($"loginTime: {loginTime} newLoginTime: {newLoginTime} prop: {userService.LoginTime}"); Debug.Log($"loginTime: {loginTime} newLoginTime: {newLoginTime} prop: {userService.LoginTime}");
} }
private static void AccoutInfo() private static void AccoutInfo()
{ {
var userService = GContext.container.Resolve<game.IUserService>() as game.PlayFabUserService; var userService = GContext.container.Resolve<game.IUserService>();
if (userService == null) return; if (userService is not game.PlayFabUserService && userService is not game.KVUserService)
{
return;
}
Debug.Log($"pfid: {userService.UserId} tyid: {userService.CustomId} lastLogin: {userService.LoginTime}"); Debug.Log($"pfid: {userService.UserId} tyid: {userService.CustomId} lastLogin: {userService.LoginTime}");
if (PlayerPrefs.HasKey("KEY_ACCOUNT_INFO")) if (PlayerPrefs.HasKey("KEY_ACCOUNT_INFO"))

View File

@@ -607,7 +607,8 @@ public class EventFishingDuelPanel : BasePanel
} }
void CreateMatchmakingTicket() void CreateMatchmakingTicket()
{ {
if (GContext.container.Resolve<IUserService>() is not PlayFabUserService) if (GContext.container.Resolve<IUserService>() is not PlayFabUserService &&
GContext.container.Resolve<IUserService>() is not KVUserService)
{ {
Debug.LogWarning("KV matchmaking service is not connected."); Debug.LogWarning("KV matchmaking service is not connected.");
FDM.MachingEnd(null); FDM.MachingEnd(null);
@@ -631,46 +632,58 @@ public class EventFishingDuelPanel : BasePanel
IUserService userService = GContext.container.Resolve<IUserService>(); IUserService userService = GContext.container.Resolve<IUserService>();
List<string> Group = FDM.GetGroups(); List<string> Group = FDM.GetGroups();
string details = GetDetails(); string details = GetDetails();
var request = new CreateMatchmakingTicketRequest var attributes = new
{ {
Creator = new MatchmakingPlayer Tier = matchTierListStr.ToArray(),
{ Map = mapIDsStr.ToArray(),
Entity = new EntityKey Group = Group.ToArray(),
{ Details = details,
Id = ((PlayFabUserService)userService).entityKey.Id,
Type = ((PlayFabUserService)userService).entityKey.Type
},
Attributes = new MatchmakingPlayerAttributes
{
DataObject = new
{
Tier = matchTierListStr.ToArray(),
Map = mapIDsStr.ToArray(),
Group = Group.ToArray(),
Details = details,
}
}
},
GiveUpAfterSeconds = MaxMatchTime,
QueueName = FishingDuelManager.QueueName // 需要根据实际的匹配队列名称修改
}; };
Action<CreateMatchmakingTicketResult> success = (result) =>
PlayFabMultiplayerAPI.CreateMatchmakingTicket(request, {
(result) => currentTicketId = result.TicketId;
Debug.Log("匹配请求已创建,等待匹配...");
Debug.Log("匹配Ticket ID: " + result.TicketId);
//// 开始轮询匹配状态
StartCoroutine(CheckMatchmakingStatus());
//显示取消按钮
but_stop.gameObject.SetActive(true);
};
Action<string> failure = (error) =>
{
Debug.LogError("创建匹配请求失败: " + error);
FDM.MachingEnd(null);
};
if (userService is KVUserService)
{
RequestKVMatch("CreateMatchmakingTicket", new
{ {
currentTicketId = result.TicketId; queueName = FishingDuelManager.QueueName,
Debug.Log("匹配请求已创建,等待匹配..."); giveUpAfterSeconds = MaxMatchTime,
Debug.Log("匹配Ticket ID: " + result.TicketId); attributes
//// 开始轮询匹配状态 }, response => response.ToObject<CreateMatchmakingTicketResult>(), success, failure);
StartCoroutine(CheckMatchmakingStatus()); }
//显示取消按钮 else
but_stop.gameObject.SetActive(true); {
}, var request = new CreateMatchmakingTicketRequest
(error) =>
{ {
Debug.LogError("创建匹配请求失败: " + error.GenerateErrorReport()); Creator = new MatchmakingPlayer
FDM.MachingEnd(null); {
}); Entity = new EntityKey
{
Id = ((PlayFabUserService)userService).entityKey.Id,
Type = ((PlayFabUserService)userService).entityKey.Type
},
Attributes = new MatchmakingPlayerAttributes
{
DataObject = attributes
}
},
GiveUpAfterSeconds = MaxMatchTime,
QueueName = FishingDuelManager.QueueName
};
PlayFabMultiplayerAPI.CreateMatchmakingTicket(request, success, error => failure(error.GenerateErrorReport()));
}
} }
IEnumerator CheckMatchmakingStatus() IEnumerator CheckMatchmakingStatus()
@@ -680,7 +693,9 @@ public class EventFishingDuelPanel : BasePanel
yield return new WaitForSeconds(3f); yield return new WaitForSeconds(3f);
if (string.IsNullOrEmpty(currentTicketId)) if (string.IsNullOrEmpty(currentTicketId))
{
yield break; yield break;
}
var request = new GetMatchmakingTicketRequest var request = new GetMatchmakingTicketRequest
{ {
@@ -689,41 +704,53 @@ public class EventFishingDuelPanel : BasePanel
EscapeObject = false EscapeObject = false
}; };
PlayFabMultiplayerAPI.GetMatchmakingTicket(request, Action<GetMatchmakingTicketResult> success = (result) =>
(result) => {
string statusInfo = "匹配状态: " + result.Status;
if (result.MatchId != null)
{ {
string statusInfo = "匹配状态: " + result.Status; statusInfo += ", Match ID: " + result.MatchId;
if (result.MatchId != null) }
{ Debug.Log(statusInfo.ToString());
statusInfo += ", Match ID: " + result.MatchId;
}
Debug.Log(statusInfo.ToString());
if (result.Status == "Matched") if (result.Status == "Matched")
{
but_stop.gameObject.SetActive(false);
Debug.Log("匹配成功,获取服务器信息: ");
GetMatchDetails(result.MatchId);
}
else if (result.Status == "Canceled")
{
Debug.Log("匹配已取消: Canceled " + result.CancellationReasonString);
//if (result.CancellationReasonString != "Requested")
//{
MachingEnd();
//}
}
},
(error) =>
{ {
Debug.LogError("检查匹配状态失败: " + error.GenerateErrorReport()); but_stop.gameObject.SetActive(false);
Debug.Log("匹配成功,获取服务器信息: ");
GetMatchDetails(result.MatchId);
}
else if (result.Status == "Canceled")
{
Debug.Log("匹配已取消: Canceled " + result.CancellationReasonString);
//if (result.CancellationReasonString != "Requested")
//{
MachingEnd(); MachingEnd();
}); //}
}
};
Action<string> failure = (error) =>
{
Debug.LogError("检查匹配状态失败: " + error);
MachingEnd();
};
if (GContext.container.Resolve<IUserService>() is KVUserService)
{
RequestKVMatch("GetMatchmakingTicket", new
{
queueName = request.QueueName,
ticketId = request.TicketId
}, response => response.ToObject<GetMatchmakingTicketResult>(), success, failure);
}
else
{
PlayFabMultiplayerAPI.GetMatchmakingTicket(request, success, error => failure(error.GenerateErrorReport()));
}
} }
} }
void GetMatchDetails(string matchId) void GetMatchDetails(string matchId)
{ {
if (GContext.container.Resolve<IUserService>() is not PlayFabUserService) if (GContext.container.Resolve<IUserService>() is not PlayFabUserService &&
GContext.container.Resolve<IUserService>() is not KVUserService)
{ {
Debug.LogWarning("KV matchmaking service is not connected."); Debug.LogWarning("KV matchmaking service is not connected.");
FDM.MachingEnd(null); FDM.MachingEnd(null);
@@ -737,52 +764,63 @@ public class EventFishingDuelPanel : BasePanel
ReturnMemberAttributes = true ReturnMemberAttributes = true
}; };
PlayFabMultiplayerAPI.GetMatch(request, Action<GetMatchResult> success = (result) =>
(result) => {
Debug.Log("匹配成功: " + result.MatchId);
if (string.IsNullOrEmpty(currentTicketId))
{ {
Debug.Log("匹配成功: " + result.MatchId); return;
if (string.IsNullOrEmpty(currentTicketId)) }
// 添加玩家信息
if (result.Members != null && result.Members.Count > 0)
{
//AddMatchmakingLog("玩家数量: " + result.Members.Count);
IUserService userService = GContext.container.Resolve<IUserService>();
MatchDetails matchDetails = null;
int[] mapArray = null;
try
{ {
return; for (int i = 0; i < result.Members.Count; i++)
}
// 添加玩家信息
if (result.Members != null && result.Members.Count > 0)
{
//AddMatchmakingLog("玩家数量: " + result.Members.Count);
IUserService userService = GContext.container.Resolve<IUserService>();
MatchDetails matchDetails = null;
int[] mapArray = null;
try
{ {
for (int i = 0; i < result.Members.Count; i++) MatchmakingPlayerWithTeamAssignment member = result.Members[i];
if (member.Entity.Id != (userService is KVUserService ? userService.UserId : ((PlayFabUserService)userService).entityKey.Id))
{ {
MatchmakingPlayerWithTeamAssignment member = result.Members[i]; JObject dataObject = JObject.FromObject(result.Members[i].Attributes.DataObject);
if (member.Entity.Id != ((PlayFabUserService)userService).entityKey.Id) string[] tierArray = dataObject["Tier"].ToObject<string[]>();
{ mapArray = dataObject["Map"].ToObject<int[]>();
JObject dataObject = JObject.FromObject(result.Members[i].Attributes.DataObject); string matchDetailsStr = dataObject["Details"].ToString();
string[] tierArray = dataObject["Tier"].ToObject<string[]>(); matchDetails = Newtonsoft.Json.JsonConvert.DeserializeObject<MatchDetails>(matchDetailsStr);
mapArray = dataObject["Map"].ToObject<int[]>(); break;
string matchDetailsStr = dataObject["Details"].ToString();
matchDetails = Newtonsoft.Json.JsonConvert.DeserializeObject<MatchDetails>(matchDetailsStr);
break;
}
} }
} }
catch
{
matchDetails = null;
mapArray = null;
}
currentTicketId = null;
FDM.MachingEnd(result.MatchId, matchDetails, mapArray);
StartCoroutine(CheckReady());
} }
}, catch
(error) => {
matchDetails = null;
mapArray = null;
}
currentTicketId = null;
FDM.MachingEnd(result.MatchId, matchDetails, mapArray);
StartCoroutine(CheckReady());
}
};
Action<string> failure = (error) =>
{
Debug.LogError("获取匹配详情失败: " + error);
MachingEnd();
};
if (GContext.container.Resolve<IUserService>() is KVUserService)
{
RequestKVMatch("GetMatch", new
{ {
Debug.LogError("获取匹配详情失败: " + error.GenerateErrorReport()); queueName = request.QueueName,
MachingEnd(); matchId = request.MatchId
}); }, ReadKVMatch, success, failure);
}
else
{
PlayFabMultiplayerAPI.GetMatch(request, success, error => failure(error.GenerateErrorReport()));
}
} }
IEnumerator CheckReady() IEnumerator CheckReady()
{ {
@@ -806,7 +844,9 @@ public class EventFishingDuelPanel : BasePanel
void CancelMatchmaking() void CancelMatchmaking()
{ {
if (string.IsNullOrEmpty(currentTicketId)) if (string.IsNullOrEmpty(currentTicketId))
{
return; return;
}
but_stop.gameObject.SetActive(false); but_stop.gameObject.SetActive(false);
var request = new CancelMatchmakingTicketRequest var request = new CancelMatchmakingTicketRequest
{ {
@@ -814,15 +854,98 @@ public class EventFishingDuelPanel : BasePanel
QueueName = FishingDuelManager.QueueName // 需要根据实际的匹配队列名称修改 QueueName = FishingDuelManager.QueueName // 需要根据实际的匹配队列名称修改
}; };
currentTicketId = null; currentTicketId = null;
PlayFabMultiplayerAPI.CancelMatchmakingTicket(request, Action<CancelMatchmakingTicketResult> success = (result) =>
(result) => {
Debug.Log("匹配已取消: ");
};
Action<string> failure = (error) =>
{
Debug.LogError("取消匹配失败: " + error);
};
if (GContext.container.Resolve<IUserService>() is KVUserService)
{
RequestKVMatch("CancelMatchmakingTicket", new
{ {
Debug.Log("匹配已取消: "); queueName = request.QueueName,
}, ticketId = request.TicketId
(error) => }, response => response.ToObject<CancelMatchmakingTicketResult>(), success, failure);
}
else
{
PlayFabMultiplayerAPI.CancelMatchmakingTicket(request, success, error => failure(error.GenerateErrorReport()));
}
}
private static async void RequestKVMatch<T>(string action, object request, Func<JObject, T> parse,
Action<T> success, Action<string> failure)
{
string body = await GContext.container.Resolve<ICustomServerMgr>().CustomServerPost("matchmaking/" + action,
Newtonsoft.Json.JsonConvert.SerializeObject(request));
T result = default;
string error = "KV matchmaking request failed";
bool accepted = false;
try
{
if (!string.IsNullOrEmpty(body))
{ {
Debug.LogError("取消匹配失败: " + error.GenerateErrorReport()); var response = Newtonsoft.Json.JsonConvert.DeserializeObject<JObject>(body);
if (response != null && response["code"]?.ToObject<int>(Newtonsoft.Json.JsonSerializer.CreateDefault()) == 1)
{
result = parse(response);
accepted = true;
}
}
}
catch (Newtonsoft.Json.JsonException ex)
{
error = "KV matchmaking response: " + ex.GetType().Name;
}
if (accepted)
{
success(result);
}
else
{
failure(error);
}
}
private static GetMatchResult ReadKVMatch(JObject response)
{
var result = new GetMatchResult
{
MatchId = response["matchId"]?.ToObject<string>()
};
var members = response["members"];
if (members == null || members.Type == JTokenType.Null)
{
return result;
}
if (members is not JArray array)
{
throw new Newtonsoft.Json.JsonSerializationException("Invalid match members.");
}
result.Members = new List<MatchmakingPlayerWithTeamAssignment>();
foreach (var token in array)
{
if (token is not JObject member || member["playerId"]?.Type != JTokenType.String ||
member["attributes"] is not JObject attributes)
{
throw new Newtonsoft.Json.JsonSerializationException("Invalid match member.");
}
result.Members.Add(new MatchmakingPlayerWithTeamAssignment
{
Entity = new EntityKey
{
Id = member["playerId"].ToObject<string>()
},
Attributes = new MatchmakingPlayerAttributes
{
DataObject = attributes
}
}); });
}
return result;
} }
#endregion Matchmaking #endregion Matchmaking

View File

@@ -1,4 +1,4 @@
// EventCubeMeltManager.cs // EventCubeMeltManager.cs
// 锻刀大赛 // 锻刀大赛
using System; using System;
@@ -171,33 +171,10 @@ public class EventCubeMeltManager
public EventCubeMeltData EventData { get; private set; } public EventCubeMeltData EventData { get; private set; }
internal static game.PlayerDataStorage.PlayerKVCubeState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeCube(raw,snapshot,errors);
internal void ResetForKVLoad()
{
EventData=null; _isInit=false; _fishingEvent=null;
EventCubeMeltMainData=null; EventCubeMeltConfig=null; EventCubeMeltStageData=null; EventCubeMeltCollectionData=null;
EventCubeMeltPieces=null; EventCubeMeltPieceItems=new List<PieceItem>(); EventCubeMeltItems=null; EventCubeMeltCollections=null;
EventStageList=null; StageCount=0; StartTime=EndTime=default; CurrentPieceItems=new List<PieceItem>(); _pieceDimension=default;
PrevData=null; NextData=null; PrevBoardData=null; PrevPieceDatas=null; CurrentBoardData=null; CurrentPieceDatas=null; GuideIndex=0;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCubeState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
EventData=!state.IsPresent ? null : new EventCubeMeltData {
TicketCount=state.TicketCount,FirstOpen=state.FirstOpen,IsFinished=state.IsFinished,EventId=state.EventId,
IsInGuide=state.IsInGuide,StartTime=state.StartTime,EndTime=state.EndTime,CurrentStagetId=state.CurrentStagetId,
CollectItems=state.CollectItems.Select(p=>new CollectItem { ItemId=p.ItemId,Count=p.Count,MaxCount=p.MaxCount,ItemTrueId=p.ItemTrueId }).ToList(),
BoardData=new List<int>(state.BoardData),BoardViewSize=new Vector2Int(state.Width,state.Height),
CollectRewardsId=new List<int>(state.CollectRewardsId),IsFinalRewardGet=state.IsFinalRewardGet,
PieceDatas=state.PieceDatas.Select(p=>new List<int>(p)).ToList(),Cycle=state.Cycle,InflationRate=state.InflationRate
};
}
internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent==null && EventCubeMeltMainData==null &&
EventCubeMeltStageData==null && EventCubeMeltCollectionData==null && EventCubeMeltPieces==null &&
EventCubeMeltPieceItems.Count==0 && CurrentPieceItems.Count==0 && PrevData==null && NextData==null &&
PrevBoardData==null && CurrentBoardData==null && PrevPieceDatas==null && CurrentPieceDatas==null;
public FishingEvent FishingEvent {get => _fishingEvent;set => _fishingEvent = value;} public FishingEvent FishingEvent {get => _fishingEvent;set => _fishingEvent = value;}

View File

@@ -7,7 +7,6 @@ using GameCore;
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using UniRx; using UniRx;
using UnityEngine; using UnityEngine;
@@ -25,52 +24,6 @@ public class ShowHandEvent
[DefaultExecutionOrder(10000)] [DefaultExecutionOrder(10000)]
public partial class FishingBehaviorB : MonoBehaviour public partial class FishingBehaviorB : MonoBehaviour
{ {
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction kvFishingAction;
bool KVFishingCurrent()
{
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true;
return this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.FishingCurrent(kvFishingAction);
}
void CancelKVFishing(string reason) => game.PlayerDataStorage.PlayerKVGameplayLoop.CancelFishing(kvFishingAction,reason);
void TrackKVCastTween(Tween tween)
{
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return;
var action=kvFishingAction;
bool completed=false;
tween.onComplete+=()=>completed=true;
tween.onKill+=()=> { if(!completed) game.PlayerDataStorage.PlayerKVGameplayLoop.CancelFishing(action,"cast tween killed"); };
}
async Task ConsumeBaitOnKVAsync()
{
try
{
if(!KVFishingCurrent()) return;
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.ClaimFishingConsumption(kvFishingAction)) return;
if(fishingData.fishItem==null || !fishingData.IsGeneralFish() || fishingData.IsFree || fishingData.IsRetry ||
fishingData.IsDuel || fishingData.IsPackItemFish() || fishingData.curFishData==null)
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Only one ordinary paid fishing action is permitted. fishItem.ID="+(fishingData.fishItem==null?"null":fishingData.fishItem.ID.ToString())+", fishItem.SubType="+(fishingData.fishItem==null?"null":fishingData.fishItem.SubType.ToString())+", curFishData.ID="+(fishingData.curFishData==null?"null":fishingData.curFishData.ID.ToString())+", IsFree="+fishingData.IsFree+", IsRetry="+fishingData.IsRetry+", IsDuel="+fishingData.IsDuel);
if(Application.internetReachability==NetworkReachability.NotReachable)
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing stopped before consumption: offline.");
if(_tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==ConditionType.ConsumeEnergy)))
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("ConsumeEnergy activity activation requires separate review.");
var player=GContext.container.Resolve<PlayerData>();
int mag=player.magnification;
if(mag<0 || mag>=_tables.TbEnergyDef.DataList.Count)
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Invalid fishing magnification.");
int fish=fishingData.curFishData.ID, rod=fishingData.RodId, map=player.lastMapId;
int cost=_tables.TbEnergyDef.DataList[mag].Mag;
game.PlayerDataStorage.PlayerKVGameplayLoop.PrepareFishingReward(kvFishingAction,fishingData);
await player.ConsumeEnergyForKVLoopAsync(cost,kvFishingAction);
if(!KVFishingCurrent()) return;
if(fishingData.curFishData==null || fishingData.curFishData.ID!=fish || fishingData.RodId!=rod ||
player.lastMapId!=map || (int)player.magnification!=mag || fishingData.IsFree || fishingData.IsRetry || fishingData.IsDuel)
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing context changed during confirmation.");
game.PlayerDataStorage.PlayerKVGameplayLoop.ContinueFishing(kvFishingAction,WaitForFishBiteED);
}
catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
}
#endif
IPlayerSaveStore saveStore; IPlayerSaveStore saveStore;
Tables _tables; Tables _tables;
[Header("RELATED OBJECT SETTINGS")] [Header("RELATED OBJECT SETTINGS")]
@@ -762,25 +715,9 @@ public partial class FishingBehaviorB : MonoBehaviour
//开始抛鱼线 //开始抛鱼线
public async void CastLineBehavior() public async void CastLineBehavior()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
try
{
var action=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginFishing(this);
if(action==null) return;
kvFishingAction=action;
if(!KVFishingCurrent()) return;
}
catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); return; }
}
#endif
//计算主画布分辨率下的宽高和屏幕的宽高的比列: //计算主画布分辨率下的宽高和屏幕的宽高的比列:
fishingData.mainCamera.DOLocalRotateQuaternion(Quaternion.identity, 0.1f); fishingData.mainCamera.DOLocalRotateQuaternion(Quaternion.identity, 0.1f);
await Awaiters.NextFrame; await Awaiters.NextFrame;
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
CastBait(); CastBait();
//播放抛竿动画 //播放抛竿动画
@@ -791,18 +728,12 @@ public partial class FishingBehaviorB : MonoBehaviour
rodHandleAni.SetBool("_idle", false); rodHandleAni.SetBool("_idle", false);
rodHandleAni.SetTrigger("_drawing"); rodHandleAni.SetTrigger("_drawing");
await Awaiters.Seconds(fishingBehaviorConf.audioDelay); await Awaiters.Seconds(fishingBehaviorConf.audioDelay);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
GContext.Publish(new EventFishingSound(SoundType.ThrowingAudio)); GContext.Publish(new EventFishingSound(SoundType.ThrowingAudio));
GContext.Publish(new VibrationData(1)); GContext.Publish(new VibrationData(1));
} }
private void ConsumeBait() private void ConsumeBait()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=ConsumeBaitOnKVAsync(); return; }
#endif
if (fishingData.IsFree) if (fishingData.IsFree)
{ {
WaitForFishBiteED(); WaitForFishBiteED();
@@ -855,8 +786,8 @@ public partial class FishingBehaviorB : MonoBehaviour
{ {
GContext.Publish(new ConditionTypeEvent(ConditionType.ConsumeEnergy, energy)); GContext.Publish(new ConditionTypeEvent(ConditionType.ConsumeEnergy, energy));
} }
var success = (await saveStore.WriteAndConfirmAsync("NewPlayerDataDic", var success = await saveStore.SyncDataImmidiate("NewPlayerDataDic",
Newtonsoft.Json.JsonConvert.SerializeObject(commonDatas))).Confirmed; Newtonsoft.Json.JsonConvert.SerializeObject(commonDatas));
Debug.Log($"[FishBehaviourB::ConsumeBaitOnPlayfab] {success}"); Debug.Log($"[FishBehaviourB::ConsumeBaitOnPlayfab] {success}");
} }
@@ -1004,9 +935,6 @@ public partial class FishingBehaviorB : MonoBehaviour
} }
public void BaitMove() public void BaitMove()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
flyEndPosTrue = _bait.transform.position; flyEndPosTrue = _bait.transform.position;
GContext.Publish(new EventFishingSound(SoundType.audio_fishing_throwing_baitintowater)); GContext.Publish(new EventFishingSound(SoundType.audio_fishing_throwing_baitintowater));
@@ -1017,12 +945,9 @@ public partial class FishingBehaviorB : MonoBehaviour
var fishEffect = _effectDic["fx_fishing_splashloopwaiting"]; var fishEffect = _effectDic["fx_fishing_splashloopwaiting"];
float z = WaitingZ(); float z = WaitingZ();
float timer = Mathf.Lerp(fishingBehaviorConf.waitingLineDrawMinTime, fishingBehaviorConf.waitingLineDrawMaxTime, Random.value); float timer = Mathf.Lerp(fishingBehaviorConf.waitingLineDrawMinTime, fishingBehaviorConf.waitingLineDrawMaxTime, Random.value);
var waitingTween=_bait.transform.DOLocalMoveZ(z, timer).OnUpdate( _bait.transform.DOLocalMoveZ(z, timer).OnUpdate(
(() => (() =>
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
flyEndPosTrue = _bait.transform.position; flyEndPosTrue = _bait.transform.position;
if (fishEffect) if (fishEffect)
{ {
@@ -1031,9 +956,6 @@ public partial class FishingBehaviorB : MonoBehaviour
fishEffect.transform.position = vector; fishEffect.transform.position = vector;
} }
})); ; })); ;
#if UNITY_EDITOR && !SERVICE_HOST
TrackKVCastTween(waitingTween);
#endif
StartCoroutine(WaitForFishBite(timer)); StartCoroutine(WaitForFishBite(timer));
} }
IEnumerator PlayWaitingFx() IEnumerator PlayWaitingFx()
@@ -1056,9 +978,6 @@ public partial class FishingBehaviorB : MonoBehaviour
} }
private IEnumerator WaitForFishBite(float timer) private IEnumerator WaitForFishBite(float timer)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) yield break;
#endif
EventFishingSound eventFishingSound1 = new EventFishingSound(SoundType.audio_fishing_waiting); EventFishingSound eventFishingSound1 = new EventFishingSound(SoundType.audio_fishing_waiting);
EventFishingSound eventFishingSound2 = new EventFishingSound(SoundType.ReelingAudio, loop: true); EventFishingSound eventFishingSound2 = new EventFishingSound(SoundType.ReelingAudio, loop: true);
@@ -1068,9 +987,6 @@ public partial class FishingBehaviorB : MonoBehaviour
timer += Mathf.Lerp(fishingBehaviorConf.waitingMinTime, fishingBehaviorConf.waitingMaxTime, Random.value) * (1 - EnhancedWaitingTime); timer += Mathf.Lerp(fishingBehaviorConf.waitingMinTime, fishingBehaviorConf.waitingMaxTime, Random.value) * (1 - EnhancedWaitingTime);
yield return new WaitForSeconds(timer); yield return new WaitForSeconds(timer);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) yield break;
#endif
if (eventFishingSound2.Sound != null) if (eventFishingSound2.Sound != null)
{ {
@@ -1194,27 +1110,18 @@ public partial class FishingBehaviorB : MonoBehaviour
} }
private async void CastBait() private async void CastBait()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
InitEffect(); InitEffect();
if (fishingBehaviorConf.fishFreeControlTime > 0f) if (fishingBehaviorConf.fishFreeControlTime > 0f)
{ {
await new WaitForSeconds(fishingBehaviorConf.fishFreeControlTime); await new WaitForSeconds(fishingBehaviorConf.fishFreeControlTime);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
_isCastLine = true; _isCastLine = true;
_bait.transform.SetParent(transform); _bait.transform.SetParent(transform);
var castX=_bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime); _bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime);
var castZ=_bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve); _bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve);
_cameraAnimator.SetTrigger("_waiting"); _cameraAnimator.SetTrigger("_waiting");
var castY=_bait.transform.DOMoveY(_waterHeight, fishingBehaviorConf.flyToWaterTime) _bait.transform.DOMoveY(_waterHeight, fishingBehaviorConf.flyToWaterTime)
.SetEase(fishingBehaviorConf.flyDownCurve).OnComplete((() => .SetEase(fishingBehaviorConf.flyDownCurve).OnComplete((() =>
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
fishingRodAnimator.SetTrigger("_waiting"); fishingRodAnimator.SetTrigger("_waiting");
GContext.Publish(ERightState.Waiting); GContext.Publish(ERightState.Waiting);
LoadEffect("fx_fishing_splashinto"); LoadEffect("fx_fishing_splashinto");
@@ -1224,25 +1131,16 @@ public partial class FishingBehaviorB : MonoBehaviour
BaitMove(); BaitMove();
_bait.SetActive(false); _bait.SetActive(false);
})); }));
#if UNITY_EDITOR && !SERVICE_HOST
TrackKVCastTween(castX); TrackKVCastTween(castZ); TrackKVCastTween(castY);
#endif
AddLinePos(); AddLinePos();
} }
else else
{ {
await new WaitForSeconds(1.15f); await new WaitForSeconds(1.15f);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
//当前动画_fishingRodAnimator播放时间 //当前动画_fishingRodAnimator播放时间
float time = fishingRodAnimator.GetCurrentAnimatorStateInfo(0).normalizedTime; float time = fishingRodAnimator.GetCurrentAnimatorStateInfo(0).normalizedTime;
_isCastLine = true; _isCastLine = true;
_cameraAnimator.SetTrigger("_waiting"); _cameraAnimator.SetTrigger("_waiting");
await new WaitForSeconds(time); await new WaitForSeconds(time);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
_bait.transform.SetParent(transform); _bait.transform.SetParent(transform);
_bait.SetActive(false); _bait.SetActive(false);
//鱼漂位置 //鱼漂位置
@@ -2023,9 +1921,6 @@ public partial class FishingBehaviorB : MonoBehaviour
_cameraAnimator.SetBool("_reeling", false); _cameraAnimator.SetBool("_reeling", false);
fishingRodAnimator.SetBool("_pulling", false); fishingRodAnimator.SetBool("_pulling", false);
CameraPullingEnd(); CameraPullingEnd();
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled && (!KVFishingCurrent() || !game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance)) yield break;
#endif
fishingData.IsCaughtFish = true; fishingData.IsCaughtFish = true;
PlayCollectingEffect(); PlayCollectingEffect();
if (fx_fishing_splashwaveloop != null) if (fx_fishing_splashwaveloop != null)
@@ -2438,9 +2333,6 @@ public partial class FishingBehaviorB : MonoBehaviour
private void OnDisable() private void OnDisable()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
CancelKVFishing("disabled");
#endif
CloseAllEffect(); CloseAllEffect();
} }
public void CloseAllEffect() public void CloseAllEffect()
@@ -2464,9 +2356,6 @@ public partial class FishingBehaviorB : MonoBehaviour
} }
private void OnDestroy() private void OnDestroy()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
CancelKVFishing("destroyed");
#endif
disposables?.Dispose(); disposables?.Dispose();
disposables = null; disposables = null;
foreach (var o in _effectDic) foreach (var o in _effectDic)

View File

@@ -522,22 +522,6 @@ public class FishingUpdateSys : MonoBehaviour
int isFailCount = 0; int isFailCount = 0;
// 按了收线按钮 // 按了收线按钮
public void PiercingFish(int waitingZone) public void PiercingFish(int waitingZone)
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
try
{
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardDrawing(this)) return;
using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("strike")) PiercingFishCore(waitingZone);
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
return;
}
#endif
PiercingFishCore(waitingZone);
}
void PiercingFishCore(int waitingZone)
{ {
//waitingZone==100表示鱼没有开启刺鱼 //waitingZone==100表示鱼没有开启刺鱼
if (!fishingData.IsDrawing) if (!fishingData.IsDrawing)
@@ -639,9 +623,6 @@ public class FishingUpdateSys : MonoBehaviour
if (piercingToComboB > 0) if (piercingToComboB > 0)
{ {
await Awaiters.Seconds(damageProtectionTime); await Awaiters.Seconds(damageProtectionTime);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
#endif
_comboMultiplayer += Random.Range(piercingToComboA, piercingToComboB); _comboMultiplayer += Random.Range(piercingToComboA, piercingToComboB);
//新技能生效 刺鱼触发全力收线进度 //新技能生效 刺鱼触发全力收线进度
PublishPerkEvent(RodPerkType.BattlePerk_PiercingToCombo); PublishPerkEvent(RodPerkType.BattlePerk_PiercingToCombo);
@@ -651,9 +632,6 @@ public class FishingUpdateSys : MonoBehaviour
public async void EternalDeath() public async void EternalDeath()
{ {
await Awaiters.Seconds(damageProtectionTime); await Awaiters.Seconds(damageProtectionTime);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
#endif
StopAllCoroutines(); StopAllCoroutines();
OnFishingIsOver(); OnFishingIsOver();
@@ -1064,10 +1042,6 @@ public class FishingUpdateSys : MonoBehaviour
private void Update() private void Update()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
#endif
if (fishingData.IsDrawing) if (fishingData.IsDrawing)
{ {
Vector3 fishPos = fishingBehavior.GetFishPos(); Vector3 fishPos = fishingBehavior.GetFishPos();
@@ -1245,34 +1219,6 @@ public class FishingUpdateSys : MonoBehaviour
} }
void FishEnd() void FishEnd()
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
try
{
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardEnding(this,fishingData.IsCaughtFish && !isFail)) return;
using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("ending")) FishEndCore();
_=ShowKVRewardPanelAsync();
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
return;
}
#endif
FishEndCore();
}
#if UNITY_EDITOR && !SERVICE_HOST
async Task ShowKVRewardPanelAsync()
{
try
{
var panel=await UIManager.Instance.ShowUI(UITypes.FishingRewardPanel);
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardRunCurrent && panel!=null) Destroy(panel);
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
}
#endif
void FishEndCore()
{ {
if (isFail) if (isFail)
{ {
@@ -1351,9 +1297,6 @@ public class FishingUpdateSys : MonoBehaviour
} }
else else
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return; // Wrapper opens UI after S2 disposal.
#endif
_ = UIManager.Instance.ShowUI(UITypes.FishingRewardPanel); _ = UIManager.Instance.ShowUI(UITypes.FishingRewardPanel);
} }
} }
@@ -1803,15 +1746,6 @@ public class FishingUpdateSys : MonoBehaviour
//全力收线 //全力收线
public async void FullCombo() public async void FullCombo()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
try { game.PlayerDataStorage.PlayerKVGameplayLoop.CheckRewardGuide(); }
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); return; }
}
#endif
if (_isCombo == 1 && curFishingStateName != FishingStateName.UIEating) if (_isCombo == 1 && curFishingStateName != FishingStateName.UIEating)
{ {
if (comboToExDmg > 0 && comboToExDmgCount < curComboToExDmgCount) if (comboToExDmg > 0 && comboToExDmgCount < curComboToExDmgCount)
@@ -1852,9 +1786,6 @@ public class FishingUpdateSys : MonoBehaviour
} }
} }
await Awaiters.Seconds(0.2f); await Awaiters.Seconds(0.2f);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
#endif
lockCombo = false; lockCombo = false;
} }
} }
@@ -3199,10 +3130,6 @@ public class FishingUpdateSys : MonoBehaviour
} }
private void OnDisable() private void OnDisable()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller disabled");
#endif
StopSoundCombo(); StopSoundCombo();
StopRodAudio(); StopRodAudio();
StopNotReeling(); StopNotReeling();
@@ -3249,10 +3176,6 @@ public class FishingUpdateSys : MonoBehaviour
} }
private void OnDestroy() private void OnDestroy()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller destroyed");
#endif
disposables?.Dispose(); disposables?.Dispose();
_comboDisposable?.Dispose(); _comboDisposable?.Dispose();
_hpcoroutine = null; _hpcoroutine = null;

View File

@@ -27,25 +27,10 @@ namespace Script.RuntimeScript
private DiggingGameModel _dataModel; private DiggingGameModel _dataModel;
public DiggingGameModel DataModel => _dataModel; public DiggingGameModel DataModel => _dataModel;
internal static game.PlayerDataStorage.PlayerKVEmptyModuleState DecodeForKVLoad() =>
game.PlayerDataStorage.PlayerKVEmptyModuleState.Instance;
internal bool CanResetForKVLoad => _monoBehaviour==null && _digAni==null && _cameraVir==null && _tables==null &&
fx_DgSanddig==null && fx_DgSanddighard==null && fx_DgSanddisappear==null && diggingMoveCurve==null &&
_broadLogic==null && _broadContainer==null && _gridContianer==null && _gridPropContianer==null &&
_propContainer==null && _showPropPositon==null && _explosionManager==null && sanddig_dig_sound==null &&
sanddig_dig_audio==null && sanddig_dighard_audio==null && _fxRoot==null && _onCloseAwardPanel==null &&
(_dataModel==null || (_dataModel.CurActivityId==0 && _dataModel.digActivityConfig==null && _dataModel.AllClamps.Count==0));
internal void ResetForKVLoad()
{
if(!CanResetForKVLoad) throw new InvalidOperationException("KV digging module is active.");
_dataModel=new DiggingGameModel(); _broadLogic=null; _gameState=SandDigGameState.Normal;
IsCanClick=true; ClampIsPass=false; itemDatas=new List<ItemData>(); _awardPanelIsOpen=false;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEmptyModuleState state)
{
if(state==null) throw new ArgumentNullException(nameof(state));
if(!CanResetForKVLoad || _dataModel==null) throw new InvalidOperationException("KV digging reset required.");
}
private Animation _digAni; private Animation _digAni;

View File

@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using asap.core;
using tysdk;
using System.Linq;
using game.PlayerDataStorage;
namespace game
{
public class AggKVUserService : KVUserService
{
private bool channelLoginRunning;
protected override string NewPlayerName
{
get
{
if (TYSdkFacade.TYAccountInfo == null)
{
return base.NewPlayerName;
}
if (TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwFacebook
|| TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwGoogle)
{
if (!string.IsNullOrEmpty(TYSdkFacade.TYAccountInfo.userName))
{
return TYSdkFacade.TYAccountInfo.userName;
}
}
return base.NewPlayerName;
}
}
protected override string NewPlayerAvatar
{
get
{
if (TYSdkFacade.TYAccountInfo == null)
{
return base.NewPlayerAvatar;
}
if (TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwFacebook
/*|| TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwGoogle*/)
{
if (!string.IsNullOrEmpty(TYSdkFacade.TYAccountInfo.avatar))
{
return TYSdkFacade.TYAccountInfo.avatar;
}
}
return base.NewPlayerAvatar;
}
}
public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true)
{
if (channelLoginRunning || LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
channelLoginRunning = true;
try
{
if (IsLogin)
{
progress?.Invoke(3);
return ELoginStep.Done;
}
UnityEngine.Debug.Log("AggUserService Login");
LoginInfo loginInfo = new LoginInfo();
if (!TYSdkFacade.IsLoggedIn)
{
loginInfo = await TYSdkFacade.Instance.LoginBySns();
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
if (!loginInfo.isSuccess && tryLoginByToken)
{
loginInfo = await TYSdkFacade.Instance.LoginByToken();
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
}
if (!loginInfo.isSuccess)
{
var evt = new LoginTypeEvt();
GContext.Publish(evt);
var loginType = await evt.loginType.Task;
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
loginInfo = await TYSdkFacade.Instance.Login(loginType);
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
}
}
else
{
loginInfo.isSuccess = true;
loginInfo.userId = TYSdkFacade.TYAccountInfo.userId;
}
if (!loginInfo.isSuccess)
{
return ELoginStep.Sdk;
}
progress.Invoke(1);
CustomId = loginInfo.StrUserId;
var step = await base.Login(step => progress?.Invoke(step + 1));
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Api;
}
if (step != ELoginStep.Done)
{
return step;
}
if (ChannelManager.IsRustoreStore)
{
GEvent.SetProp("sdk_channel", "ru_store");
}
GEvent.SetProps(new Dictionary<string, object>()
{
{"pf_id", UserId},
{"CreateTime",CreateTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")}
});
GEvent.SetUserProps(new Dictionary<string, object>()
{
{"pf_id", UserId},
{"CreateTime",CreateTime.ToString("yyyy-MM-dd HH:mm:ss")}
});
AvatarToKV(TYSdkFacade.TYAccountInfo);
GEvent.OnLogin(UserId, CustomId);
if (config.Get<bool>("NewlyCreated", false))
{
using (var e = GEvent.CompleteRegistrationEvent())
{
e.AddContent("login_type", TYSdkFacade.TYAccountInfo.channel.ToString());
}
GEvent.SetProps(new Dictionary<string, object>()
{
{"vip_level", 0}
});
GEvent.SetUserProps(new Dictionary<string, object>()
{
{"vip_level", 0},
{"total_consumption", 0}
});
if (AFHelper.Instance.GetDLReferer(out var pid, out var uid))
{
UnityEngine.Debug.Log("[AggUserService] deeplink referer:" + pid + " " + uid);
using (var e = GEvent.GameEvent("deeplink_invited"))
{
e.AddContent("referrer_uid", uid);
e.AddContent("referrer_pid", pid);
}
var refererData = new Dictionary<string, object>()
{
{"refererPid", pid},
{"refererUid", uid},
{"playerUid", CustomId}
};
_ = customServerMgr.CustomServerPost("friend/RecoreReferer",
Newtonsoft.Json.JsonConvert.SerializeObject(refererData));
}
}
using (var e = GEvent.LoginEvent())
{
e.AddContent("login_type", TYSdkFacade.TYAccountInfo.channel.ToString());
e.AddContent("display_name", DisplayName);
e.AddContent("avatar_url", AvatarUrl);
e.AddContent("linkedAccout", string.Join('#', TYSdkFacade.TYAccountInfo.linkedAccout));
}
return step;
}
finally
{
channelLoginRunning = false;
}
}
public override void Logout()
{
TYSdkFacade.Instance.Logout();
base.Logout();
}
public override async Task<bool> GetLinkState(EAccoutType accoutType)
{
return await TYSdkFacade.Instance.LinkCheck(accoutType);
}
public override async Task<(int code, int userid)> LinkAccount(EAccoutType accoutType)
{
var linkResult = await TYSdkFacade.Instance.LinkAccount(accoutType);
if (linkResult.code == 0 && !LoginLifetime.IsCancellationRequested)
{
var _accountInfo = TYSdkFacade.TYAccountInfo;
if (_accountInfo != null)
{
AvatarToKV(_accountInfo);
if (_accountInfo.channel == EAccoutType.hwFacebook)
{
_ = UpdateAvatarUrl(_accountInfo.avatar);
}
}
}
return (linkResult.code, linkResult.userId);
}
protected override void InitData(IDictionary<string, string> data)
{
if (data.TryGetValue(EAccoutType.hwGoogle.ToString(), out var google) && google == "1")
{
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwGoogle);
}
if (data.TryGetValue(EAccoutType.hwFacebook.ToString(), out var facebook) && facebook == "1")
{
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwFacebook);
}
if (data.TryGetValue(EAccoutType.Apple.ToString(), out var apple) && apple == "1")
{
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.Apple);
}
if (TYSdkFacade.TYAccountInfo.channel != EAccoutType.hwGuest && TYSdkFacade.TYAccountInfo.channel != EAccoutType.none)
{
var channel = TYSdkFacade.TYAccountInfo.channel.ToString();
if (!data.ContainsKey(channel))
{
data.Add(channel, "1");
SaveStore.Write(channel, "1").RequireAccepted();
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f3774b22ff7e4fd894180c42bed01400

View File

@@ -13,17 +13,6 @@ namespace game
public class AggUserService : PlayFabUserService public class AggUserService : PlayFabUserService
{ {
public AggUserService() { }
internal AggUserService(PlayerDataStorage.PlayerKVControlledLoadCapability capability) : base(capability) { }
internal IReadOnlyList<PlayerDataStorage.PlayerKVIdentityChannel> KVLinkedAccounts { get; private set; } =
Array.Empty<PlayerDataStorage.PlayerKVIdentityChannel>();
internal override void ResetForKVLoad()
{
base.ResetForKVLoad();
KVLinkedAccounts=Array.Empty<PlayerDataStorage.PlayerKVIdentityChannel>();
}
internal override void ReplaceKVLinked(PlayerDataStorage.PlayerKVLoginState state)
{ KVLinkedAccounts=new List<PlayerDataStorage.PlayerKVIdentityChannel>(state.Linked).AsReadOnly(); }
protected override string NewPlayerName protected override string NewPlayerName
{ {
get get
@@ -56,15 +45,6 @@ namespace game
public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true) public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
CustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId;
OnInitData+=InitData;
try { return await LoginWithBackend(progress,"kv-loop"); }
finally { OnInitData-=InitData; }
}
#endif
var backend=CaptureSaveBackend(); var backend=CaptureSaveBackend();
if(RejectSaveBackend(backend)) return ELoginStep.Api; if(RejectSaveBackend(backend)) return ELoginStep.Api;
var generation = logoutGeneration; var generation = logoutGeneration;
@@ -190,9 +170,8 @@ namespace game
public override void Logout() public override void Logout()
{ {
base.Logout();
if(IsControlledKVInstance) return;
TYSdkFacade.Instance.Logout(); TYSdkFacade.Instance.Logout();
base.Logout();
} }
public override async Task<bool> GetLinkState(EAccoutType accoutType) public override async Task<bool> GetLinkState(EAccoutType accoutType)
@@ -218,25 +197,8 @@ namespace game
return (linkResult.code, linkResult.userId); return (linkResult.code, linkResult.userId);
} }
internal int KVNotificationCount { get; private set; }
internal override async Task<PlayerDataStorage.PlayerKVGameLoadResult> LoadKVForValidationAsync(PlayerDataStorage.PlayerKVGameLoadCapability capability)
{
EnterKVReceiver(ReceiveKVData);
try { return await base.LoadKVForValidationAsync(capability); }
finally { ExitKVReceiver(); }
}
void ReceiveKVData(IDictionary<string,string> data)
{
if(!IsControlledKVInstance || KVLoadingCopy==null || data.Count!=KVLoadingCopy.Count)
throw new InvalidOperationException("Controlled notification window invalid.");
KVNotificationCount++;
}
private void InitData(IDictionary<string, string> data) private void InitData(IDictionary<string, string> data)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
// Editor loop has no channel SDK login. M31 already replaced account URLs and linked flags.
if(PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return;
#endif
if (data.TryGetValue(EAccoutType.hwGoogle.ToString(), out var google) && google == "1") if (data.TryGetValue(EAccoutType.hwGoogle.ToString(), out var google) && google == "1")
{ {
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwGoogle); TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwGoogle);

View File

@@ -1,4 +1,4 @@
using asap.core; using asap.core;
using cfg; using cfg;
using GameCore; using GameCore;
using System; using System;
@@ -67,19 +67,33 @@ namespace game
} }
internal bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) => internal bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) =>
requiresFormalKV && ReferenceEquals(promptOwner, owner); requiresFormalKV && ReferenceEquals(promptOwner, owner);
internal void ReleaseFailedPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner)
{
if (promptOwner == null)
{
return;
}
if (!ReferenceEquals(promptOwner, owner) || owner.Status != PlayerDataStorage.PlayerKVSyncStatus.Closed)
{
throw DefaultSettingService.PromptFailure("IsOpenClubRed: failed owner has not closed.");
}
promptOwner = null;
LastPromptEdit = null;
}
internal void RequirePromptOwner() internal void RequirePromptOwner()
{ {
if (promptOwner != null) ReadPrompt(); if (promptOwner != null) ReadPrompt();
else if (requiresFormalKV) else if (requiresFormalKV)
throw DefaultSettingService.PromptFailure("IsOpenClubRed: formal owner not handed off."); throw DefaultSettingService.PromptFailure("IsOpenClubRed: formal owner not handed off.");
} }
PlayerDataStorage.PlayerKVKeyReadResult ReadPrompt() private PlayerDataStorage.PlayerKVKeyReadResult ReadPrompt()
{ {
if (LastPromptEdit != null && !LastPromptEdit.Accepted)
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
var read = KVMgr.ReadKV(promptOwner, "IsOpenClubRed"); var read = KVMgr.ReadKV(promptOwner, "IsOpenClubRed");
if (!read.Success) if (!read.Success)
{
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {read.Error}"); throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {read.Error}");
}
return read; return read;
} }
// END B01 fixed prompt owner // END B01 fixed prompt owner
@@ -101,25 +115,10 @@ namespace game
LureDonateData lureDonateData; LureDonateData lureDonateData;
// KV loading owns these three fields only; membership, transport and chat lifecycle stay untouched. // KV loading owns these three fields only; membership, transport and chat lifecycle stay untouched.
internal static PlayerDataStorage.PlayerKVClubState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
PlayerDataStorage.PlayerKVSaveSchema.DecodeClub(raw,snapshot,errors);
internal void ResetForKVLoad()
{
signTime = null;
lureDonateData = new LureDonateData();
IsOpenClubRed = true;
}
internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVClubState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
var donation = new LureDonateData { DonateCountTime=state.DonateTime,
ClubLureDonateCount=state.DonateCount, ClubLureRequestCDEnd=state.RequestEnd };
signTime = state.SignTime; lureDonateData = donation; IsOpenClubRed = state.OpenRed;
}
internal PlayerDataStorage.PlayerKVClubState SnapshotForKVValidation() =>
new PlayerDataStorage.PlayerKVClubState(signTime,lureDonateData.DonateCountTime,
lureDonateData.ClubLureDonateCount,lureDonateData.ClubLureRequestCDEnd,IsOpenClubRed);
public int ClubLureDonateCount => lureDonateData.ClubLureDonateCount; public int ClubLureDonateCount => lureDonateData.ClubLureDonateCount;
public DateTime ClubLureRequestCDEnd => lureDonateData.ClubLureRequestCDEnd; public DateTime ClubLureRequestCDEnd => lureDonateData.ClubLureRequestCDEnd;
@@ -203,20 +202,24 @@ namespace game
public void SetOpenClubRed() public void SetOpenClubRed()
{ {
if (requiresFormalKV && promptOwner == null) RequirePromptOwner(); if (requiresFormalKV && promptOwner == null)
{
RequirePromptOwner();
}
if (promptOwner != null) if (promptOwner != null)
{ {
var read = ReadPrompt(); ReadPrompt();
// M14: any present string means read, including "0" and the empty string. if (IsOpenClubRed)
if (read.Found != true)
{ {
IsOpenClubRed = false;
SetClubRed();
LastPromptEdit = KVMgr.WriteKV(promptOwner, "IsOpenClubRed", "1"); LastPromptEdit = KVMgr.WriteKV(promptOwner, "IsOpenClubRed", "1");
if (!LastPromptEdit.Accepted) if (!LastPromptEdit.Accepted)
{
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}"); throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
}
} }
RequirePromptOwner(); RequirePromptOwner();
IsOpenClubRed = false;
SetClubRed();
return; return;
} }
if (IsOpenClubRed) if (IsOpenClubRed)

View File

@@ -0,0 +1,6 @@
namespace game
{
public sealed class DevKVUserService : KVUserService
{
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ccd01c3a579486180804cefdbc73f9d

View File

@@ -1,4 +1,4 @@
using System; using System;
public interface IFootPrintService public interface IFootPrintService
{ {
public EFootPrint FootPrint { get; } public EFootPrint FootPrint { get; }
@@ -11,27 +11,14 @@ public interface IFootPrintService
// ReSharper disable once ClassNeverInstantiated.Global // ReSharper disable once ClassNeverInstantiated.Global
public class FootPrintService : IFootPrintService public class FootPrintService : IFootPrintService
{ {
[asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } [asap.core.Inject]
public game.PlayerDataStorage.IPlayerSaveStore SaveStore
{
get;
set;
}
private EFootPrint _footPrint; private EFootPrint _footPrint;
private ulong _kvCompleteFootPrint;
internal ulong CompleteFootPrintForKVValidation => _kvCompleteFootPrint;
internal static game.PlayerDataStorage.PlayerKVFootPrintState DecodeForKVLoad(
System.Collections.Generic.IReadOnlyDictionary<string, string> raw,
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFootPrint(raw, errors);
internal void ResetForKVLoad()
{
_kvCompleteFootPrint = 0;
_footPrint = 0;
}
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVFootPrintState state)
{
if (state == null) throw new ArgumentNullException(nameof(state));
_kvCompleteFootPrint = state.Bits;
// This legacy view contains only the low 32 bits. The ulong field retains the complete KV state.
_footPrint = unchecked((EFootPrint)(int)(uint)state.Bits);
}
public EFootPrint FootPrint => _footPrint; public EFootPrint FootPrint => _footPrint;
public void UpdateFootPrint(EFootPrint newState) public void UpdateFootPrint(EFootPrint newState)
{ {
@@ -41,15 +28,13 @@ public class FootPrintService : IFootPrintService
public void UploadData() public void UploadData()
{ {
_kvCompleteFootPrint |= unchecked((uint)(int)_footPrint); var data = (ulong)_footPrint;
var data = _kvCompleteFootPrint;
SaveStore.Write("FootPrint", data.ToString()).RequireAccepted(); SaveStore.Write("FootPrint", data.ToString()).RequireAccepted();
} }
public void LoadData(string data) public void LoadData(string data)
{ {
_kvCompleteFootPrint = ulong.Parse(data); _footPrint = (EFootPrint) ulong.Parse(data);
_footPrint = unchecked((EFootPrint)(int)(uint)_kvCompleteFootPrint);
} }
public bool HasFootPrint(EFootPrint state) public bool HasFootPrint(EFootPrint state)

View File

@@ -48,6 +48,32 @@ namespace game
public int Code { get; set; } public int Code { get; set; }
public string Msg { get; set; } public string Msg { get; set; }
public BFriendInfo? Info { get; set; } public BFriendInfo? Info { get; set; }
public KvPublicProfile KvProfile
{
get;
set;
}
}
public class KvPublicProfile
{
public string DisplayName
{
get;
set;
}
public string AvatarUrl
{
get;
set;
}
public int? Level
{
get;
set;
}
} }
public class GetHomePageReq public class GetHomePageReq

View File

@@ -142,7 +142,9 @@ namespace game
} }
}; };
var result = await PlayFabClientAsyncAPI.GetFriendsListAsync(getFriendsListRequest); var result = userService is KVUserService
? await GetKVFriendsListAsync()
: await PlayFabClientAsyncAPI.GetFriendsListAsync(getFriendsListRequest);
var list = result?.Friends; var list = result?.Friends;
if (list != null) if (list != null)
{ {
@@ -214,6 +216,57 @@ namespace game
#endif #endif
} }
} }
private async Task<GetFriendsListResult> GetKVFriendsListAsync()
{
var json = await customServerMgr.FriendRequest("GetFriendsList", new { });
if (string.IsNullOrEmpty(json))
{
return null;
}
try
{
var response = Newtonsoft.Json.JsonConvert.DeserializeObject<GetInvitedListResp>(json);
if (response == null || response.Code != 1 || response.Friends == null)
{
return null;
}
var friends = new List<FriendInfo>();
foreach (var serialized in response.Friends)
{
if (string.IsNullOrEmpty(serialized))
{
return null;
}
var item = Newtonsoft.Json.JsonConvert.DeserializeObject<KvFriendItem>(serialized);
if (item == null)
{
return null;
}
friends.Add(new FriendInfo { FriendPlayFabId = item.PlayFabId, Profile = item.Profile });
}
return new GetFriendsListResult { Friends = friends };
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogError("KV friends response failed: " + ex.GetType().Name);
return null;
}
}
private sealed class KvFriendItem
{
public string PlayFabId
{
get;
set;
}
public PlayerProfileModel Profile
{
get;
set;
}
}
#endregion #endregion
#region 邀请好友 #region 邀请好友

View File

@@ -40,7 +40,6 @@ public class LocalNotificationService : ILocalNotificationService
DefaultSettingService promptSettings; DefaultSettingService promptSettings;
Task initialization; Task initialization;
Task opening; Task opening;
internal bool HasKVPromptOwner => promptSettings != null;
public LocalNotificationService() public LocalNotificationService()
{ {
_notificationList = GContext.container.Resolve<cfg.Tables>().TbNotification.DataList; _notificationList = GContext.container.Resolve<cfg.Tables>().TbNotification.DataList;

View File

@@ -11,33 +11,49 @@ using game.PlayerDataStorage;
using GameCore; using GameCore;
using tysdk; using tysdk;
using UnityEngine; using UnityEngine;
using Newtonsoft.Json;
using YiDun.Protector;
#if UNITY_ANDROID
using TapjoyUnity.Internal;
#endif
namespace game namespace game
{ {
// One service belongs to one login lifetime. A revoked instance is never rebound. // Failed login attempts may retry; explicit logout ends this service lifetime.
public sealed class KVUserService : IUserService, IKVUserLoadReceiver, IKVUserLifecycle public class KVUserService : IUserService, IKVUserLifecycle
{ {
[Inject] public ICustomServerMgr customServerMgr { get; set; } [Inject]
[Inject] public IPlayerSaveStore SaveStore { get; set; } public IConfig config { get; set; }
readonly PlayerKVRuntimeSelection selection;
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory; [Inject]
readonly CancellationTokenSource lifetime = new CancellationTokenSource(); public ICustomServerMgr customServerMgr { get; set; }
readonly Dictionary<string, PlayInfo> profiles = new Dictionary<string, PlayInfo>(); [Inject]
readonly Dictionary<string, int> levels = new Dictionary<string, int>(); public IPlayerSaveStore SaveStore { get; set; }
PlayerKVGameLoadCoordinator coordinator;
PlayerKVGameSessionHandle handle; //
PlayerKVInitializationIntents intents; private readonly PlayerKVRuntimeSelection selection;
bool attempted, loading, closing, published, promptsBound; //
Task closeTask; private readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
long epoch;
string playerId; private readonly CancellationTokenSource lifetime = new();
uint internalId; private readonly Dictionary<string, PlayInfo> profiles = new();
private readonly Dictionary<string, int> levels = new();
private PlayerKVGameLoadCoordinator coordinator;
private PlayerKVGameSessionHandle handle;
private bool loading, closing, published, promptsBound;
private PlayerKVGameSessionHandle failedHandle;
private int mmTestValue;
private Task closeTask;
private long epoch;
private string playerId;
private uint internalId;
public string CustomId { get; set; } public string CustomId { get; set; }
public string UserId => IsAuthorized ? playerId : null; public string UserId => KVHandoffReady ? playerId : null;
public string ApiPlayerId => UserId; public string ApiPlayerId => UserId;
public uint InternalId => IsAuthorized ? internalId : 0; public uint InternalId => KVHandoffReady ? internalId : 0;
public bool IsLogin => IsAuthorized; public bool IsLogin => KVHandoffReady && playerId != null;
public bool IsAuthorized => published && KVHandoffReady; public bool IsAuthorized => published && InternalId != 0 && KVHandoffReady;
public CancellationToken LoginLifetime => lifetime.Token; public CancellationToken LoginLifetime => lifetime.Token;
public DateTime LastLoginTime { get; private set; } public DateTime LastLoginTime { get; private set; }
public DateTime LoginTime { get; private set; } public DateTime LoginTime { get; private set; }
@@ -47,7 +63,6 @@ namespace game
public string Language { get; private set; } public string Language { get; private set; }
public string ContinentCode { get; private set; } public string ContinentCode { get; private set; }
public Dictionary<string, string> EAccoutUrl { get; private set; } = new Dictionary<string, string>(); public Dictionary<string, string> EAccoutUrl { get; private set; } = new Dictionary<string, string>();
internal int KVTapJoyBalance { get; private set; }
internal PlayerKVGameLoadError KVLastError { get; private set; } internal PlayerKVGameLoadError KVLastError { get; private set; }
internal bool KVHandoffReady => !closing && handle != null && coordinator != null && internal bool KVHandoffReady => !closing && handle != null && coordinator != null &&
coordinator.TryGetCurrentGameHandle(epoch, out var current) && ReferenceEquals(current, handle); coordinator.TryGetCurrentGameHandle(epoch, out var current) && ReferenceEquals(current, handle);
@@ -57,88 +72,315 @@ namespace game
public KVUserService() : this(SubBoostrap.RuntimeSelection, jar => new HttpClientHandler public KVUserService() : this(SubBoostrap.RuntimeSelection, jar => new HttpClientHandler
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { } { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
internal KVUserService(PlayerKVRuntimeSelection selection, Func<CookieContainer, HttpMessageHandler> handlerFactory) private KVUserService(PlayerKVRuntimeSelection selection, Func<CookieContainer, HttpMessageHandler> handlerFactory)
{ {
this.selection = selection; this.selection = selection;
this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
Application.quitting += Logout; Application.quitting += OnApplicationQuitting;
} }
public async Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true) protected virtual string NewPlayerName => $"Angler#{CustomId}";
protected virtual string NewPlayerAvatar => string.Empty;
protected virtual void InitData(IDictionary<string, string> data)
{ {
if (attempted || closing || selection == null || !selection.IsKV || }
!selection.TryAdmitPlayer(CustomId, out var canonicalId))
{ KVLastError = PlayerKVGameLoadError.Input; return ELoginStep.Api; } public virtual async Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true)
// Set before the first await: duplicate calls cannot authenticate or subscribe twice. {
attempted = true; if (loading || closing)
{
return ELoginStep.Api;
}
if (IsLogin)
{
progress?.Invoke(2);
return ELoginStep.Done;
}
if (selection == null || !selection.IsKV || !selection.TryAdmitPlayer(CustomId, out var canonicalId))
{
KVLastError = PlayerKVGameLoadError.Input;
return ELoginStep.Api;
}
loading = true;
var requestedId = CustomId; var requestedId = CustomId;
bool successEventStarted = false;
IChatService chat = null;
try try
{ {
if (customServerMgr is not CustomServerMgr server || CustomServerMgr server;
server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl) KVPlayerSaveStore store;
throw new InvalidOperationException("Formal KV transport required."); PlayerKVGameLoadResult result;
loading = true; PlayerKVGameSessionHandle original;
var transaction = PlayerKVGameplayBindings.Bind(this); try
coordinator = new PlayerKVGameLoadCoordinator(transaction, {
() => loading && !closing && !published && handle == null, if (!await ReleaseFailedLoginAsync() || closing)
() => { }, () => intents?.Count ?? 0, server.BindKVForGame, server.ClearKVForGame); {
server.KVUnauthorized += OnUnauthorized; return ELoginStep.Api;
var identity = new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none, }
Array.Empty<PlayerKVIdentityChannel>()); server = customServerMgr as CustomServerMgr;
var snapshot = PlayerKVGameplayBindings.Snapshot(DateTime.UtcNow, "formal-v1"); if (server == null || server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl)
var capability = PlayerKVGameLoadCapability.CreateForGame(identity, snapshot, selection.Api, {
Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory); throw new InvalidOperationException("Formal KV transport required.");
var result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime, }
() => !closing && CustomId == requestedId); coordinator = new PlayerKVGameLoadCoordinator(
KVLastError = result.Error; () => loading && !closing && !published && handle == null,
if (result.State != PlayerKVGameLoadState.GameLoaded || closing || CustomId != requestedId || server.BindKVForGame, server.ClearKVForGame);
result.Account == null || result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch || server.KVUnauthorized += OnUnauthorized;
!coordinator.TryGetCurrentGameHandle(result.Epoch, out var original)) var capability = PlayerKVGameLoadCapability.CreateForGame(selection.Api,
{ Logout(); return ELoginStep.Api; } Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory, prepareOriginalComparison: true);
epoch = result.Epoch; result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime,
handle = original; () => !closing && CustomId == requestedId);
if (SaveStore is not KVPlayerSaveStore store) throw new InvalidOperationException("KV save store required."); KVLastError = result.Error;
store.Bind(original, LoginLifetime); if (result.State != PlayerKVGameLoadState.PreparedForGame || result.Data == null ||
PrepareProfile(result.Account); closing || CustomId != requestedId || result.Account == null ||
BindPromptServices(original); result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch ||
// Chat subscribes before the unique success event. No SDK or analytics initialization here. !coordinator.TryGetCurrentGameHandle(result.Epoch, out original))
GContext.container.Resolve<IChatService>().WaitingForLogin(); {
if (!KVHandoffReady || CustomId != requestedId) throw new InvalidOperationException("Stale KV login."); await FailLoginAsync();
server.EnableKVBusiness(epoch); return ELoginStep.Api;
}
epoch = result.Epoch;
handle = original;
store = SaveStore as KVPlayerSaveStore;
if (store == null)
{
throw new InvalidOperationException("KV save store required.");
}
store.Bind(original, LoginLifetime);
}
catch (Exception ex)
{
KVLastError = PlayerKVGameLoadError.Apply;
Debug.LogError("KV preparation failed: " + ex.GetType().Name);
await FailLoginAsync();
return ELoginStep.Api;
}
await PrepareProfileAsync(result.Account);
if (!KVHandoffReady || CustomId != requestedId)
{
await FailLoginAsync();
return ELoginStep.Api;
}
playerId = result.Account.PlayerId; playerId = result.Account.PlayerId;
internalId = result.Account.InternalId; internalId = result.Account.InternalId;
ZZTimeHelper.AdjustTime(LoginTime);
ZZTimeHelper.WathForcusChange();
if (!original.CompareDatas(ZZTimeHelper.UtcNow().Ticks).Success)
{
KVLastError = PlayerKVGameLoadError.Apply;
await FailLoginAsync();
return ELoginStep.Api;
}
var merged = original.ReadLocalSnapshot();
if (!merged.Success)
{
KVLastError = PlayerKVGameLoadError.Apply;
await FailLoginAsync();
return ELoginStep.Api;
}
KVMgr.Bind(store);
InitializePlayerData(merged.Data.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal));
if (!KVHandoffReady || CustomId != requestedId)
{
await FailLoginAsync();
return ELoginStep.Api;
}
SetMMTestValue();
try
{
BindPromptServices(original);
}
catch (Exception ex)
{
KVLastError = PlayerKVGameLoadError.Apply;
Debug.LogError("KV prompt binding failed: " + ex.GetType().Name);
await FailLoginAsync();
return ELoginStep.Api;
}
chat = GContext.container.Resolve<IChatService>();
chat.WaitingForLogin();
OnYinDunLogin();
if (!KVHandoffReady || CustomId != requestedId)
{
await FailLoginAsync();
return ELoginStep.Api;
}
server.EnableKVBusiness(epoch);
published = true; published = true;
GContext.Publish(new EvtAPISvrLoginSuccess { playerId = playerId, internalId = internalId }); successEventStarted = true;
if (!IsAuthorized) return ELoginStep.Api; try
_ = RunSaveScheduleAsync(store); {
GContext.Publish(new EvtAPISvrLoginSuccess { playerId = playerId, internalId = internalId });
}
catch (Exception ex)
{
// Original LoginApiServer catches subscriber errors and removes API authorization.
Debug.LogError(ex);
internalId = 0;
published = false;
server.DisableKVBusiness();
}
if (closing)
{
return ELoginStep.Api;
}
InitTapJoy();
progress?.Invoke(2); progress?.Invoke(2);
return ELoginStep.Done; return ELoginStep.Done;
} }
catch (Exception ex) catch (Exception ex)
{ {
if (successEventStarted)
{
throw;
}
KVLastError = PlayerKVGameLoadError.Apply; KVLastError = PlayerKVGameLoadError.Apply;
Debug.LogError("KV login failed: " + ex.GetType().Name); Debug.LogError("KV login failed: " + ex.GetType().Name);
Logout(); try
return ELoginStep.Api; {
(chat as IDisposable)?.Dispose();
}
catch (Exception cleanupError)
{
Debug.LogError("KV chat cleanup failed: " + cleanupError);
}
try
{
await FailLoginAsync();
}
catch (Exception cleanupError)
{
Debug.LogError("KV login cleanup failed: " + cleanupError);
}
throw;
}
finally
{
loading = false;
} }
finally { loading = false; }
} }
void PrepareProfile(PlayerKVAccountDescriptor account) private void InitializePlayerData(Dictionary<string, string> data)
{
mmTestValue = 0;
if (!data.ContainsKey("MMTest"))
{
if (config.Get<bool>("NewlyCreated", false))
{
mmTestValue = -1;
}
else
{
SaveStore.Write("MMTest", "MMFailParam#0").RequireAccepted();
}
}
else
{
var parts = data["MMTest"].Split('#');
if (parts.Length > 1 && parts[0] == "MMFailParam")
{
int.TryParse(parts[1], out var value);
mmTestValue = value;
}
else
{
mmTestValue = 0;
SaveStore.Write("MMTest", "MMFailParam#0").RequireAccepted();
}
}
GContext.container.Resolve<PlayerData>().LoadPlayerData(data);
InitData(data);
}
private async Task PrepareProfileAsync(PlayerKVAccountDescriptor account)
{ {
if (account.InternalId == 0 || account.CreateTimeUtc == DateTime.MinValue || account.ServerTimeUtc == DateTime.MinValue) if (account.InternalId == 0 || account.CreateTimeUtc == DateTime.MinValue || account.ServerTimeUtc == DateTime.MinValue)
{
throw new InvalidOperationException("Authenticated account metadata required."); throw new InvalidOperationException("Authenticated account metadata required.");
}
CreateTime = account.CreateTimeUtc; CreateTime = account.CreateTimeUtc;
LoginTime = account.ServerTimeUtc; LoginTime = account.ServerTimeUtc;
config.Set("NewlyCreated", account.NewlyCreated);
var previous = Read("LastLoginTimeUtc"); var previous = Read("LastLoginTimeUtc");
LastLoginTime = ParseLastLoginTime(previous.Value, previous.Found.Value); LastLoginTime = ParseLastLoginTime(previous.Value, previous.Found.Value);
DisplayName = Read("DisplayName").Value; DisplayName = Read("DisplayName").Value;
if (string.IsNullOrEmpty(DisplayName)) DisplayName = GetDefaultName(account.PlayerId);
AvatarUrl = Read("AvatarUrl").Value ?? string.Empty; AvatarUrl = Read("AvatarUrl").Value ?? string.Empty;
Language = Read("Language").Value; Language = Read("Language").Value;
if (string.IsNullOrEmpty(Language)) Language = GContext.container.Resolve<ISettingService>().Lang;
var continent = Read("ContinentCode").Value; var continent = Read("ContinentCode").Value;
ContinentCode = new[] { "AF", "AN", "AS", "EU", "NA", "OC", "SA" }.Contains(continent) ? continent : null; ContinentCode = new[] { "AF", "AN", "AS", "EU", "NA", "OC", "SA" }.Contains(continent) ? continent : null;
if (string.IsNullOrEmpty(DisplayName))
{
var name = await ChangeDisplayName(NewPlayerName);
LoginLifetime.ThrowIfCancellationRequested();
if (!KVHandoffReady)
{
throw new InvalidOperationException("Stale KV profile.");
}
DisplayName = name;
}
if (string.IsNullOrEmpty(AvatarUrl))
{
await UpdateAvatarUrl(NewPlayerAvatar);
}
await UpdateLanguage(GContext.container.Resolve<ISettingService>().Lang);
LoginLifetime.ThrowIfCancellationRequested();
}
private async Task FailLoginAsync()
{
if (closing)
{
return;
}
if (closeTask != null)
{
await ReleaseFailedLoginAsync();
return;
}
failedHandle = handle;
handle = null;
playerId = null;
internalId = 0;
published = false;
DisplayName = AvatarUrl = Language = ContinentCode = null;
ClearPlayInfos();
if (customServerMgr is CustomServerMgr server)
{
server.KVUnauthorized -= OnUnauthorized;
server.DisableKVBusiness();
}
closeTask = coordinator?.Revoke() ?? Task.CompletedTask;
await ReleaseFailedLoginAsync();
}
private async Task<bool> ReleaseFailedLoginAsync()
{
if (closeTask == null)
{
return true;
}
var status = await (coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready));
if (closing || status != PlayerKVShutdownStatus.Ready || closeTask.Status != TaskStatus.RanToCompletion)
{
KVLastError = status == PlayerKVShutdownStatus.Failed || closeTask.IsFaulted ?
PlayerKVGameLoadError.PreviousCloseFailed : PlayerKVGameLoadError.PreviousClosePending;
return false;
}
if (failedHandle != null)
{
KVMgr.Unbind(SaveStore as KVPlayerSaveStore);
(SaveStore as KVPlayerSaveStore)?.ReleaseFailedBinding(failedHandle);
(GContext.container.Resolve<ISettingService>() as DefaultSettingService)?.ReleaseFailedPromptOwner(failedHandle);
GContext.container.Resolve<ClubService>().ReleaseFailedPromptOwner(failedHandle);
}
promptsBound = false;
failedHandle = null;
closeTask = null;
coordinator = null;
epoch = 0;
return true;
} }
internal static DateTime ParseLastLoginTime(string value, bool found) internal static DateTime ParseLastLoginTime(string value, bool found)
@@ -185,7 +427,31 @@ namespace game
if (version == customServerMgr.SessionVersion) Logout(); if (version == customServerMgr.SessionVersion) Logout();
} }
public void Logout() => ObserveClose(CloseKVForValidationAsync()); private void OnApplicationQuitting()
{
var original = SaveStore as KVPlayerSaveStore;
try
{
KVMgr.Stop(original);
if (original != null && original.IsCurrent)
{
_ = original.SyncDataAsync();
}
}
catch (Exception ex)
{
Debug.LogError("KV quit save failed: " + ex.GetType().Name);
}
finally
{
Logout();
}
}
public virtual void Logout()
{
ObserveClose(CloseKVForValidationAsync());
}
async void ObserveClose(Task task) async void ObserveClose(Task task)
{ {
try { await task; } try { await task; }
@@ -193,14 +459,21 @@ namespace game
} }
internal Task CloseKVForValidationAsync() internal Task CloseKVForValidationAsync()
{ {
if (closeTask != null) return closeTask; if (closing)
{
return closeTask ?? Task.CompletedTask;
}
closing = true; closing = true;
published = false; published = false;
KVMgr.Unbind(SaveStore as KVPlayerSaveStore);
(SaveStore as KVPlayerSaveStore)?.Dispose(); (SaveStore as KVPlayerSaveStore)?.Dispose();
playerId = null; internalId = 0; handle = null; epoch = 0; playerId = null;
internalId = 0;
handle = null;
epoch = 0;
DisplayName = AvatarUrl = Language = ContinentCode = null; DisplayName = AvatarUrl = Language = ContinentCode = null;
ClearPlayInfos(); ClearPlayInfos();
Application.quitting -= Logout; Application.quitting -= OnApplicationQuitting;
if (customServerMgr is CustomServerMgr server) if (customServerMgr is CustomServerMgr server)
{ {
server.KVUnauthorized -= OnUnauthorized; server.KVUnauthorized -= OnUnauthorized;
@@ -216,74 +489,244 @@ namespace game
coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready); coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready);
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync(); Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
void IKVUserLoadReceiver.ResetForKVLoad() private async Task<bool> SaveProfile(string key, string value)
{
if ((!loading && !closing) || published || handle != null)
throw new InvalidOperationException("KV identity must be unpublished during load/reset.");
intents?.Discard(); intents = null;
EAccoutUrl = new Dictionary<string, string>(); KVTapJoyBalance = 0;
}
void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state, long loadEpoch, IReadOnlyDictionary<string, string> raw)
{
if (!loading || closing || published || handle != null || state == null || raw == null)
throw new InvalidOperationException("KV load receiver is not current.");
intents = new PlayerKVInitializationIntents(loadEpoch, state.Channel);
foreach (var pair in state.Normalizations) intents.Add(loadEpoch, pair.Key, pair.Value);
EAccoutUrl = state.AccountUrls.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
KVTapJoyBalance = state.TapJoyBalance;
}
void IKVUserLoadReceiver.DiscardKVIntents() => intents?.Discard();
async Task SaveProfile(string key, string value)
{ {
var original = handle;
var store = SaveStore; var store = SaveStore;
if (!IsAuthorized || store == null) throw new InvalidOperationException("KV identity unavailable."); if ((!IsLogin && !loading) || !KVHandoffReady || store == null)
var result = await store.WriteAndConfirmAsync(key, value, LoginLifetime); {
if (!IsAuthorized || !ReferenceEquals(store, SaveStore) || !store.IsCurrent || !result.Confirmed) return false;
throw new InvalidOperationException("KV profile save was not confirmed."); }
}
async Task RunSaveScheduleAsync(IPlayerSaveStore store)
{
try try
{ {
while (IsAuthorized && store.IsCurrent) var result = await store.SyncDataImmidiate(key, value);
{ return ReferenceEquals(original, handle) && KVHandoffReady &&
await Task.Delay(TimeSpan.FromSeconds(40), LoginLifetime); ReferenceEquals(store, SaveStore) && store.IsCurrent && result;
if (!IsAuthorized || !store.IsCurrent) return; }
var result = await store.FlushAsync(LoginLifetime); catch (OperationCanceledException)
if (!result.Confirmed && !LoginLifetime.IsCancellationRequested) {
Debug.LogWarning("KV scheduled save: " + result.Status); return false;
}
} }
catch (OperationCanceledException) { }
catch (Exception ex) { Debug.LogError("KV save scheduler failed: " + ex.GetType().Name); }
} }
public async Task<string> ChangeDisplayName(string newName) public async Task<string> ChangeDisplayName(string newName)
{ {
await SaveProfile("DisplayName", newName); var originName = string.IsNullOrEmpty(DisplayName) ? NewPlayerName : DisplayName;
DisplayName = newName; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return newName; if (!await SaveProfile("DisplayName", newName))
{
return originName;
}
DisplayName = newName;
GContext.Publish(new ChangeAvatarEvent());
UpdateProfileName();
return newName;
} }
public async Task<string> UpdateAvatarUrl(string url) public async Task<string> UpdateAvatarUrl(string url)
{ {
await SaveProfile("AvatarUrl", url); var originUrl = AvatarUrl;
AvatarUrl = url; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return url;
Debug.Log("UpdateAvatarUrlAsync originUrl:" + originUrl + " url " + url);
if (string.IsNullOrEmpty(url))
{
return originUrl;
}
if (url == originUrl)
{
return originUrl;
}
if (!await SaveProfile("AvatarUrl", url))
{
return originUrl;
}
AvatarUrl = url;
GContext.Publish(new ChangeAvatarEvent());
UpdateProfileAvatar();
return url;
} }
public async Task<string> UpdateLanguage(string code) public async Task<string> UpdateLanguage(string code)
{ await SaveProfile("Language", code); Language = code; return code; } {
if (Language == code)
{
return Language;
}
if (await SaveProfile("Language", code.Replace('_', '-')))
{
Language = code;
}
return Language;
}
public void GMMMT(int value)
{
mmTestValue = value;
SaveStore.Write("MMTest", $"MMFailParam#{mmTestValue}").RequireAccepted();
SetMMTestValue();
}
private void SetMMTestValue()
{
string ab = GContext.container.Resolve<MMTService>().Init(mmTestValue);
GEvent.SetUserProp("MMTestValue", ab, true);
Debug.Log($"UserId:{UserId} MMTestValue :0b{Convert.ToString(mmTestValue, 2)} MMTestValueString:{ab}");
}
protected void AvatarToKV(TYSdkFacade.AccountInfo accountInfo)
{
if (accountInfo != null &&
(accountInfo.channel == EAccoutType.hwFacebook || accountInfo.channel == EAccoutType.hwGoogle) &&
!string.IsNullOrEmpty(accountInfo.avatar))
{
if (EAccoutUrl == null)
{
EAccoutUrl = new Dictionary<string, string>();
}
EAccoutUrl[accountInfo.channel.ToString()] = accountInfo.avatar;
SaveStore.Write("EAccoutUrl", JsonConvert.SerializeObject(EAccoutUrl)).RequireAccepted();
}
}
private void InitTapJoy()
{
#if UNITY_ANDROID
var lastBalanceStr = Read("TapJoy_Diamond").Value ?? "";
Debug.Log($"InitTapJoy -> {lastBalanceStr}");
var lastBalance = int.TryParse(lastBalanceStr, out var temp) ? temp : 0;
var tapjoyObject = GameObject.FindObjectOfType<TapjoyComponent>();
if (tapjoyObject && tapjoyObject.gameObject && !tapjoyObject.GetComponent<TapjoyHelper>())
{
var tapjoyHelper = tapjoyObject.gameObject.AddComponent<TapjoyHelper>();
GameObject.DontDestroyOnLoad(tapjoyHelper);
tapjoyHelper.Init(lastBalance, OnEarnCurrencyName, OnBalance);
}
return;
void OnEarnCurrencyName(string currencyName, int amount)
{
Debug.Log($"OnEarnCurrencyName -> {currencyName} {amount}");
// var amount = (balance - lastBalance);
var diamonds = new ItemData { id = 1005, count = amount };
diamonds.curCount = (ulong)GContext.container.Resolve<PlayerItemData>().GetItemCount(diamonds.id);
GContext.container.Resolve<PlayerItemData>().AddItem(diamonds, null);
GContext.Publish(new ShowData(new List<ItemData> { diamonds }, RewardType.NormalOne));
GContext.Publish(new ShowData());
#if AGG
using (var e = GEvent.GameEvent("tapjoy"))
{
e.AddContent("item_id", diamonds.id)
.AddContent("item_count", diamonds.count);
}
#endif
}
void OnBalance(string currencyName, int balance)
{
Debug.Log($"OnBalance -> {currencyName} {balance}");
// const string tapjoyKey = "TapJoy_Diamond";
// var lastBalanceStr = PlayFabMgr.Instance.GetLocalData(tapjoyKey) ?? "";
// var lastBalance = int.TryParse(lastBalanceStr, out var temp) ? temp : 0;
{
// PlayFabMgr.Instance.UpdateUserDataValue(tapjoyKey, balance.ToString());
// if (balance > lastBalance)
// {
// var amount = (balance - lastBalance);
// var diamonds = new ItemData { id = 1005, count = amount };
// diamonds.curCount = (ulong)GContext.container.Resolve<PlayerItemData>().GetItemCount(diamonds.id);
// GContext.container.Resolve<PlayerItemData>().AddItem(diamonds);
// GContext.Publish(new ShowData(new List<ItemData> { diamonds }, RewardType.NormalOne));
// // GContext.Publish(new ShowData(diamonds));
// GContext.Publish(new ShowData());
// }
}
}
#endif
}
private async void OnYinDunLogin()
{
// 开发模式下,先不启用了
if (config != null && (config.Get("DEBUG", "0") == "1" || config.Get("GM", "0") == "1"))
{
return;
}
var userService = GContext.container.Resolve<IUserService>();
var yiDunManager = GContext.container.Resolve<YiDunManager>();
var roleName = userService.DisplayName;
var roleId = userService.UserId;
var roleAccount = userService.CustomId;
var versionData = new
{
GameVersion = Application.version,
AssetVersion = config.Get(GConstant.K_AssetVersion, string.Empty),
#if UNITY_ANDROID
Channel = GConstant.Yidun_GooglePlayChannel
#elif UNITY_IOS
Channel = GConstant.Yidun_IOSChannel
#endif
};
Debug.Log($"JsonUtility.ToJson(versionData) => {JsonConvert.SerializeObject(versionData)}");
var businessId = GConstant.Yidun_AndroidBusinessId;
#if UNITY_IOS
businessId = GConstant.Yidun_IOSBusinessId;
#endif
#if (UNITY_ANDROID || UNITY_IOS || UNITY_IPHONE) && !UNITY_EDITOR
yiDunManager.SetRoleInfo(businessId, roleId, roleName, roleAccount, null, -1, JsonConvert.SerializeObject(versionData));
yiDunManager.PostRiskCheck();
#endif
}
public double CreateTotalSeconds() => (ZZTimeHelper.UtcNow() - CreateTime).TotalSeconds; public double CreateTotalSeconds() => (ZZTimeHelper.UtcNow() - CreateTime).TotalSeconds;
// The non-AGG channel has no account-link operation; never manufacture a successful binding. // The non-AGG channel has no account-link operation; never manufacture a successful binding.
public Task<(int code, int userid)> LinkAccount(EAccoutType type) => Task.FromResult((1, 0)); public virtual async Task<(int code, int userid)> LinkAccount(EAccoutType type)
public Task<bool> GetLinkState(EAccoutType type) => Task.FromResult(false); {
await Awaiters.NextFrame;
return (1, 0);
}
public virtual async Task<bool> GetLinkState(EAccoutType type)
{
await Awaiters.NextFrame;
return false;
}
public string GetDefaultName(string id) => "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length))); public string GetDefaultName(string id) => "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length)));
public void SetPlayerLv(string id, int level) => levels[id] = id == UserId ? GContext.container.Resolve<PlayerData>().lv : level; public void SetPlayerLv(string id, int level) => levels[id] = id == UserId ? GContext.container.Resolve<PlayerData>().lv : level;
public int GetPlayerLv(string id) => levels.TryGetValue(id, out var level) ? level : 1; public int GetPlayerLv(string id) => levels.TryGetValue(id, out var level) ? level : 1;
public Task<(string, int)> GetPlayLv(string id) public async Task<(string, int)> GetPlayLv(string id)
{ {
var level = id == UserId && IsAuthorized ? (int)GContext.container.Resolve<PlayerData>().lv : var level = id == UserId && IsAuthorized ? (int)GContext.container.Resolve<PlayerData>().lv :
id != null && levels.TryGetValue(id, out var cachedLevel) ? cachedLevel : -1; id != null && levels.TryGetValue(id, out var cachedLevel) ? cachedLevel : -1;
return level >= 0 ? Task.FromResult((id, level)) : if (level >= 0)
Task.FromException<(string, int)>(new InvalidOperationException("Public player level has not been loaded.")); {
return (id, level);
}
if (id != UserId && !string.IsNullOrEmpty(id) && !levels.ContainsKey(id))
{
var json = await customServerMgr.FriendRequest("SearchFriend", new SearchFriendReq { PlayFabId = id });
if (!string.IsNullOrEmpty(json))
{
SearchFriendResp response = null;
try
{
response = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchFriendResp>(json);
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogError("KV player level response failed: " + ex.GetType().Name);
}
if (response != null && response.Code > 0 && response.KvProfile != null)
{
level = response.KvProfile.Level ?? 1;
if (level >= 0)
{
levels[id] = level;
return (id, level);
}
}
}
}
throw new InvalidOperationException("Public player level has not been loaded.");
} }
public PlayInfo GetPlayInfo(string id) public PlayInfo GetPlayInfo(string id)
{ {
@@ -296,7 +739,7 @@ namespace game
} }
return profile; return profile;
} }
async void SetPlayInfo(string id, PlayInfo profile) private async void SetPlayInfo(string id, PlayInfo profile)
{ {
if (id == UserId) if (id == UserId)
{ {
@@ -306,27 +749,63 @@ namespace game
return; return;
} }
var json = await customServerMgr.FriendRequest("SearchFriend", new SearchFriendReq { PlayFabId = id }); var json = await customServerMgr.FriendRequest("SearchFriend", new SearchFriendReq { PlayFabId = id });
if (string.IsNullOrEmpty(json)) return; if (string.IsNullOrEmpty(json))
{
return;
}
SearchFriendResp result; SearchFriendResp result;
try { result = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchFriendResp>(json); } try
{
result = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchFriendResp>(json);
}
catch (Newtonsoft.Json.JsonException ex) catch (Newtonsoft.Json.JsonException ex)
{ {
Debug.LogError("KV player profile response failed: " + ex.GetType().Name); Debug.LogError("KV player profile response failed: " + ex.GetType().Name);
return; return;
} }
if (result != null && result.Code > 0 && result.Info != null) if (result != null && result.Code > 0 && result.KvProfile != null)
{ {
if (!string.IsNullOrEmpty(result.Info.DisplayName)) profile.name = result.Info.DisplayName; if (!string.IsNullOrEmpty(result.KvProfile.DisplayName))
profile.avatarUrl = result.Info.AvatarUrl; {
profile.name = result.KvProfile.DisplayName;
}
profile.avatarUrl = result.KvProfile.AvatarUrl;
GContext.Publish(new GetClubPlayInfoEvent { id = id, name = profile.name, avatarUrl = profile.avatarUrl }); GContext.Publish(new GetClubPlayInfoEvent { id = id, name = profile.name, avatarUrl = profile.avatarUrl });
} }
} }
public void SetPlayInfo(string id, string name, string avatarUrl) public void SetPlayInfo(string id, string name, string avatarUrl)
{ {
if (string.IsNullOrEmpty(id)) return; profiles[id] = new PlayInfo
profiles[id] = new PlayInfo { name = string.IsNullOrEmpty(name) ? GetDefaultName(id) : name, avatarUrl = avatarUrl }; {
name = string.IsNullOrEmpty(name) ? GetDefaultName(id) : name,
avatarUrl = avatarUrl
};
} }
public void ClearPlayInfos() { profiles.Clear(); levels.Clear(); } public void ClearPlayInfos() { profiles.Clear(); levels.Clear(); }
private void UpdateProfileName()
{
if (!IsAuthorized)
{
return;
}
PlayerProfile playerProfile = new PlayerProfile();
playerProfile.DisplayName = DisplayName;
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
}
private void UpdateProfileAvatar()
{
if (!IsAuthorized)
{
return;
}
PlayerProfile playerProfile = new PlayerProfile();
playerProfile.Avatar = AvatarUrl;
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
}
public void UpdateProfile() public void UpdateProfile()
{ {
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId, if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,

View File

@@ -1,11 +0,0 @@
using System.Collections.Generic;
namespace game.PlayerDataStorage
{
internal interface IKVUserLoadReceiver
{
void ResetForKVLoad();
void ReplaceFromKVDecoded(PlayerKVLoginState state, long epoch, IReadOnlyDictionary<string, string> raw);
void DiscardKVIntents();
}
}

View File

@@ -10,9 +10,7 @@ namespace game.PlayerDataStorage
bool IsCurrent { get; } bool IsCurrent { get; }
PlayerSaveReadResult Read(string key); PlayerSaveReadResult Read(string key);
PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true); PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true);
Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default); Task<bool> SyncDataImmidiate(string key, string value);
Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default);
Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default);
} }
public sealed class PlayerSaveReadResult public sealed class PlayerSaveReadResult
@@ -50,13 +48,4 @@ namespace game.PlayerDataStorage
} }
} }
// Buffered and LocalOnly are deliberately different from cloud confirmation.
public enum PlayerSaveStatus { Confirmed, Buffered, LocalOnly, Rejected, Superseded, Unknown, Cancelled }
public sealed class PlayerSaveConfirmation
{
public PlayerSaveStatus Status { get; }
public bool Confirmed => Status == PlayerSaveStatus.Confirmed;
public string Error { get; }
internal PlayerSaveConfirmation(PlayerSaveStatus status, string error = null) { Status = status; Error = error; }
}
} }

View File

@@ -0,0 +1,609 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using asap.core;
using GameCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace game.PlayerDataStorage
{
// Editor-only probes of the existing business methods; no production decoding or reset graph.
internal static class Module02OriginalLoadTests
{
private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
private static bool running;
private static bool ContainsPayload(JToken actual, JToken expected)
{
if (expected is JObject obj)
{
return actual is JObject && obj.Properties().All(p => ContainsPayload(actual[p.Name], p.Value));
}
if (expected is JArray array)
{
return actual is JArray other && array.Count == other.Count && Enumerable.Range(0, array.Count).All(i => ContainsPayload(other[i], array[i]));
}
return JToken.DeepEquals(actual, expected);
}
internal static T Field<T>(object target, string name)
{
return (T)target.GetType().GetField(name, InstanceFlags).GetValue(target);
}
private static void Invoke(object target, string name, params object[] arguments)
{
try
{
target.GetType().GetMethod(name, InstanceFlags).Invoke(target, arguments);
}
catch (TargetInvocationException ex)when (ex.InnerException != null)
{
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
throw;
}
}
internal static void LoadCore(PlayerData player, IDictionary<string, string> modern)
{
typeof(PlayerData).GetField("NewPlayerDataDic", InstanceFlags).SetValue(player, new Dictionary<string, string>(modern));
Invoke(player, "SetPlayerData");
}
internal static void LoadCore(PlayerData player, object[] slots)
{
LoadCore(player, new Dictionary<string, string> { ["newData"] = string.Join(",", slots.Select(value => Convert.ToString(value, CultureInfo.CurrentCulture))), ["vipL"] = "-1,1" });
}
internal static void LoadGuide(GuideDataCenter guide, IDictionary<string, string> raw)
{
if (raw.TryGetValue("GuideSaveData", out var legacy))
{
guide.GuideSaveDataDic = JsonConvert.DeserializeObject<Dictionary<string, GuideSaveData>>(legacy);
}
if (raw.TryGetValue("GuideSaveDataNew", out var modern))
{
guide.SetData(modern);
}
}
private sealed class ContextAccess : GContext
{
protected override Task SetupAsync()
{
throw new InvalidOperationException("Validation context cannot start gameplay.");
}
internal static void Use(Container value)
{
container = value;
}
internal static IEventAggregator Events { get => eventAggregator; set => eventAggregator = value; }
}
private sealed class MemoryStore : IPlayerSaveStore
{
private readonly Dictionary<string, string> data = new Dictionary<string, string>();
internal readonly List<string> Writes = new List<string>();
public bool IsCurrent => true;
public PlayerSaveReadResult Read(string key)
{
return new PlayerSaveReadResult(data.TryGetValue(key, out var value), value);
}
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
{
data[key] = value;
Writes.Add(key);
return new PlayerSaveWriteResult(new PlayerSaveReceipt(this, null));
}
public Task<bool> SyncDataImmidiate(string key, string value)
{
Write(key, value);
return Task.FromResult(true);
}
}
private sealed class FaceObserver : IFaceUIService
{
internal readonly List<string> Calls = new List<string>();
public void AddFaceCustomUIData(UIType type, bool first = false)
{
}
public void ShowFaceUI(string value = "")
{
}
public void AddGiftFaceUIForce(int id, UIType type)
{
Calls.Add("force:" + id + ":" + type);
}
public void AddGiftFaceUIOnLogin(int id, UIType type, int priority)
{
}
public void AddGiftFaceUI(int id, UIType type, int priority = 0)
{
Calls.Add("gift:" + id + ":" + type + ":" + priority);
}
public int GetFaceUICount() => Calls.Count;
public void StopFaceUI()
{
}
public void AddPostFaceUI(UIType type)
{
}
public void ShowPostFaceUI()
{
}
public int GetPostFaceUICount() => 0;
public void StopPostFaceUI()
{
}
}
private sealed class Fixture : IDisposable
{
private readonly Container previous = GContext.container;
private readonly IEventAggregator previousEvents = ContextAccess.Events;
private readonly Container isolated = new Container();
private readonly string previousPinataPath = UITypes.PinataPack_PonyPanel.Path;
private readonly string previousPinataName = UITypes.PinataPack_PonyPanel.Name;
private readonly Dictionary<string, UIType> previousUITypes = new Dictionary<string, UIType>(UITypes.UITypesDic);
private readonly object previousRedPoints = typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null);
internal readonly cfg.Tables Tables;
internal readonly PlayerData Player;
internal readonly PlayerFishData Fish;
internal readonly FishingEventData Events;
internal readonly PlayerShopData Shop;
internal readonly GuideDataCenter Guide;
internal readonly BattlePassData Battle;
internal readonly MemoryStore Store = new MemoryStore();
internal readonly FaceObserver Face = new FaceObserver();
internal Fixture()
{
var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData"));
Tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory, name + ".json"))));
ContextAccess.Use(isolated);
ContextAccess.Events = new EventAggregator();
typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, new RedPointManager());
Register(Tables);
isolated.RegisterInstance<IFaceUIService>(Face);
Player = Register(new PlayerData(Tables, null) { playerSaveStore = Store });
Register(new SmallGameData(Tables, null, null));
Fish = Register(new PlayerFishData(Tables, null));
Events = Register(new FishingEventData(Tables));
Shop = Register(new PlayerShopData { _tables = Tables, SaveStore = Store });
Register(new BuffDataCenter(Tables));
Register(new CampDataMM(Tables, null));
Register(new AchievementDataManager(Tables));
Register(new FishingBoxData());
Guide = Register(new GuideDataCenter(Tables, Player));
Battle = Register(new BattlePassData(Tables) { playerSaveStore = Store });
Register(new SigninData(Tables));
Register(new TriggerPackData(Tables));
Register(new ClubService(false));
Register(new FishingChallengeManager());
Register(new DataCenter.OfferChainsChestManager());
Register(new DataCenter.OfferDiscoPartyManager());
Register(new DataCenter.EventPartnerGatherManager());
Register(new EventCubeMeltManager());
Register(new EventPackData());
Register(new PiggyBankPackData());
Register(new BargainPackData());
isolated.RegisterInstance<IFootPrintService>(new FootPrintService());
Register(new EventShootingRangeData());
Register(new EventPotionDataManager());
Register(new EventPinballDataManager());
Register(new EventScratchTicketDataManager());
Register(new Script.RuntimeScript.DiggingGameManager());
Register(new PlayerItemData(Tables));
// Original constructor, after its existing dependencies are available.
Register(new EventPinataPackDataManager());
}
private T Register<T>(T instance)
where T : class
{
isolated.RegisterInstance<T>(instance);
return instance;
}
internal T Get<T>()
where T : class
{
return isolated.Resolve<T>();
}
public void Dispose()
{
ContextAccess.Use(previous);
ContextAccess.Events = previousEvents;
typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, previousRedPoints);
UITypes.PinataPack_PonyPanel.SetType(previousPinataPath);
UITypes.PinataPack_PonyPanel.SetName(previousPinataName);
UITypes.UITypesDic.Clear();
foreach (var pair in previousUITypes)
{
UITypes.UITypesDic[pair.Key] = pair.Value;
}
isolated.Dispose();
}
}
internal static string InspectRaw(Dictionary<string, string> raw)
{
using (var fixture = new Fixture())
{
fixture.Player.LoadPlayerData(raw);
return JsonConvert.SerializeObject(new { originalLoad = true, keys = raw.Count, level = (int)fixture.Player.lv, writes = fixture.Store.Writes, faceCalls = fixture.Face.Calls, formalRequests = 0 });
}
}
internal static Task<string> RunAsync(string caseId)
{
if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayerKVGameplayLoop.Enabled)
{
throw new InvalidOperationException("Original load validation requires an idle isolated Editor.");
}
running = true;
var previousContainer = GContext.container;
var previousEvents = ContextAccess.Events;
var previousDiff = ZZTimeHelper.timeDiff;
var previousOffset = ZZTimeHelper.LocalTimeOffsetInSec;
var traces = new List<object>();
int assertions = 0;
string failure = null;
void Check(bool condition, string name)
{
assertions++;
if (!condition)
{
throw new InvalidOperationException(name);
}
}
try
{
// Baseline executes the unchanged original method; target executes the KV private common entry.
// The third path deliberately reproduces the former pre-adjust load for a visible counterexample.
foreach (var seconds in new[]
{
-1,
1
}
)
{
foreach (var offset in new[]
{
-3600,
3600
}
)
{
var outputs = new List<(int eventId, DateTime energyTime, int limitedCount)>();
foreach (var path in new[]
{
"original-after-adjust",
"kv-after-adjust",
"previous-before-adjust"
}
)
{
using (var fixture = new Fixture())
{
var boundary = new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc);
var selected = fixture.Tables.TbFishingEvent.DataList.First(e => e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPackManager.GetOrDefault(e.RedirectID)?.PackType == 3);
var time = (cfg.LimitedTime)selected.TimeDefinition;
var globalOffset = offset < 0 ? -9 : 9;
typeof(cfg.GlobalConfig).GetProperty("GlobalTimeOffset").SetValue(Field<cfg.GlobalConfig>(fixture.Tables.TbGlobalConfig, "_data"), globalOffset);
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(time, boundary.AddDays(-1).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(time, boundary.ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
var raw = new Dictionary<string, string>
{
["MMTest"] = "MMFailParam#0",
["EventPackData"] = JsonConvert.SerializeObject(new { currentEventID = selected.ID, purchaseCount = 2, redirectID = selected.RedirectID }),
["LimitedTimeEventDic"] = JsonConvert.SerializeObject(new Dictionary<int, object> { [900001] = new { id = 900001, duration = 2, startTime = boundary.AddHours(-globalOffset).AddMinutes(-2).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture) } })
};
ZZTimeHelper.LocalTimeOffsetInSec = offset;
var desired = boundary.AddSeconds(seconds);
ZZTimeHelper.AdjustTime((path == "previous-before-adjust" ? boundary.AddDays(2) : desired).AddSeconds(-offset));
if (path == "kv-after-adjust")
{
var user = new KVUserService
{
SaveStore = fixture.Store
};
Invoke(user, "InitializePlayerData", raw);
}
else
{
fixture.Player.LoadPlayerData(raw);
}
var eventId = fixture.Get<EventPackData>().CurrentEventID;
var energyTime = Field<DateTime>(fixture.Player, "_lastEnergyRecoveryTime");
var limitedCount = ((System.Collections.IDictionary)Field<object>(fixture.Events, "limitedTimeEventDic")).Count;
outputs.Add((eventId, energyTime, limitedCount));
traces.Add(new { path, seconds, offset, globalOffset, desired, eventId, energyTime, limitedCount, faceCalls = fixture.Face.Calls.ToArray(), writes = fixture.Store.Writes.ToArray() });
Check(fixture.Face.Calls.Count == 0, "M20 original commented UI remains inactive");
}
}
Check(outputs[0].eventId == outputs[1].eventId, "Original and KV activity boundary match");
Check(outputs[0].limitedCount == outputs[1].limitedCount && outputs[0].limitedCount == (seconds < 0 ? 1 : 0), "LimitedTime expiry uses original global offset on both paths");
Check(Math.Abs((outputs[0].energyTime - outputs[1].energyTime).TotalSeconds) < 1, "Original and KV energy clock match");
Check(outputs[0].eventId == (seconds < 0 ? 0 : outputs[2].eventId), "M20 original expiry/reset semantics");
Check(Math.Abs((outputs[2].energyTime - outputs[1].energyTime).TotalHours) > 24, "Old load-before-adjust clock counterexample is real");
}
}
// Existing refresh lifecycle is tested separately; login loading must not invoke it early.
foreach (var globalOffset in new[]
{
-9,
9
}
)
{
foreach (var seconds in new[]
{
-1,
1
}
)
{
using (var fixture = new Fixture())
{
typeof(cfg.GlobalConfig).GetProperty("GlobalTimeOffset").SetValue(Field<cfg.GlobalConfig>(fixture.Tables.TbGlobalConfig, "_data"), globalOffset);
var eventData = fixture.Tables.TbFishingEvent.DataList.First(e => fixture.Tables.TbBattlePassMain.GetOrDefault(e.RedirectID) != null);
fixture.Battle.BattlePassRecord.ID = eventData.ID;
using (var provider = new BattlePassDataProvider(fixture.Tables, fixture.Battle, fixture.Get<PlayerItemData>()))
{
GContext.container.RegisterInstance(provider);
ZZTimeHelper.LocalTimeOffsetInSec = 0;
var desired = new DateTime(2026, 9, 27, 0, 0, 0, DateTimeKind.Utc).AddHours(globalOffset).AddSeconds(seconds);
ZZTimeHelper.AdjustTime(desired);
fixture.Battle.RefreshDaiyTask();
fixture.Battle.RefreshWeeklyTask();
var offsetNow = ZZTimeHelper.UtcNow().UtcNowOffset();
Check(fixture.Battle.BattleTask.DailyTaskEndTime == ConvertTools.GetDateTimeYMD(offsetNow).AddDays(1), "BP original daily lifecycle boundary");
Check(fixture.Battle.BattleTask.WeeklyTaskEndTime == ConvertTools.GetDateTimeSunDay(offsetNow).AddDays(1), "BP original weekly lifecycle boundary");
Check(fixture.Store.Writes.Contains("BattlePassRecordTask"), "BP original lifecycle saves through existing store");
traces.Add(new { path = "original-BP-refresh-lifecycle", globalOffset, seconds, desired, dailyEnd = fixture.Battle.BattleTask.DailyTaskEndTime, weeklyEnd = fixture.Battle.BattleTask.WeeklyTaskEndTime, writes = fixture.Store.Writes.ToArray() });
}
}
}
}
using (var fixture = new Fixture())
{
var boundary = new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc);
fixture.Player.LoadPlayerData(new Dictionary<string, string>());
ZZTimeHelper.LocalTimeOffsetInSec = 0;
ZZTimeHelper.AdjustTime(boundary.AddSeconds(-1));
var selected = fixture.Tables.TbFishingEvent.DataList.First(e => e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPackManager.GetOrDefault(e.RedirectID)?.PackType == 3);
var limit = (cfg.LimitedTime)selected.TimeDefinition;
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(limit, boundary.AddDays(-1).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(limit, boundary.ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
var bargain = fixture.Get<BargainPackData>();
var pack = fixture.Tables.TbEventPackManager[selected.RedirectID];
var packId = pack.VIPPackList[0][0];
var saved = new GeneralEventPackData(eventID: selected.ID, redirectID: selected.RedirectID, vip: 0, purchaseCount: 0, isTriggered: true, savingProgress: int.MaxValue, discountLvl: fixture.Tables.TbSpecialPack[packId].IAPID.Count - 1, inflation: 1);
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1 && fixture.Face.Calls[0].StartsWith("force:" + selected.ID + ":"), "M22 original full active bargain registers original UI once");
saved.isTriggered = false;
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1, "M22 untriggered bargain does not register UI");
saved.isTriggered = true;
ZZTimeHelper.AdjustTime(boundary.AddSeconds(1));
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1, "M22 expired bargain does not register UI");
traces.Add(new { path = "original-bargain-side-effects", calls = fixture.Face.Calls.ToArray() });
fixture.Face.Calls.Clear();
var pinataEvent = fixture.Tables.TbFishingEvent.DataList.First(e => e.Type == 9 && e.SubType == 6 && e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPinataPackMain.GetOrDefault(e.RedirectID) != null);
foreach (var ev in fixture.Tables.TbFishingEvent.DataList.Where(e => e.Type == 9 && e.SubType == 6))
{
if (ev.TimeDefinition is cfg.LimitedTime other)
{
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(other, boundary.AddDays(-2).ToString("s"));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(other, boundary.AddDays(-1).ToString("s"));
}
}
var pinataTime = (cfg.LimitedTime)pinataEvent.TimeDefinition;
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(pinataTime, boundary.AddDays(-1).ToString("s"));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(pinataTime, boundary.ToString("s"));
typeof(cfg.FishingEvent).GetProperty("ConditionList").SetValue(pinataEvent, new List<cfg.EventCondition> { new cfg.EventCondition(cfg.ConditionType.AccountLevel, new List<string> { "1" }, "") });
ZZTimeHelper.AdjustTime(boundary.AddSeconds(-1));
var pinata = fixture.Get<EventPinataPackDataManager>();
pinata.LoadData("{\"EventID\":0,\"PinataMainID\":0,\"PurchasedCount\":7}");
var target = fixture.Get<EventPinataPackData>();
Check(target.EventID == pinataEvent.ID && target.PurchasedCount == 0 && fixture.Face.Calls.Count == 1, "M29 original changed event refreshes purchase state and registers UI");
pinata.LoadData(JsonConvert.SerializeObject(new { target.EventID, target.PinataMainID, PurchasedCount = 1 }));
Check(target.PurchasedCount == 1 && fixture.Face.Calls.Count == 1, "M29 same event preserves purchase count without repeated UI");
ZZTimeHelper.AdjustTime(boundary.AddSeconds(1));
pinata.GetFreshPinataPackData();
Check(fixture.Face.Calls.Count == 1, "M29 expired event does not add UI");
traces.Add(new { path = "original-pinata-side-effects", calls = fixture.Face.Calls.ToArray(), target.EventID, target.PinataMainID, target.PurchasedCount });
}
using (var fixture = new Fixture())
{
var raw = PlayerKVEditorValidationHost.FullRaw(fixture.Tables, true);
fixture.Player.LoadPlayerData(raw);
Check((int)fixture.Player.lv == 3 && (ulong)fixture.Player.gold == 999 && (int)fixture.Player.Energy == 5, "Original newData wins over defaults");
Check(fixture.Shop.Voucher == 6 && fixture.Shop.shopPackDailyBuyCount["x"] == 1, "Shop numeric dictionaries");
Check(fixture.Events.TurntableCoupon == 7 && fixture.Events.WheelLevelCount[5] == 6, "Event original defaults and saved counts");
Check(fixture.Guide.GuideSaveDataDic["A"].guideSubIndex == 3 && fixture.Guide.GuideSaveDataDic["A"].isFinish, "Guide original modern format");
Check(fixture.Battle.BattlePassRecord.Level == 2 && fixture.Battle.BattleTask.dic_dailyTasks[9] == 10, "BP legacy values");
var small = fixture.Get<SmallGameData>();
Check(small.HeistGameCount == 12 && small.BombGameCount == 13 && small.FishHuntGameCount == 14, "Original small game slots");
Check(Field<Dictionary<string, int>>(fixture.Get<CampDataMM>(), "CampDataDic")["CampId"] == 205, "Original Camp dictionary");
Check(Field<Dictionary<int, FishBuffTimeData>>(fixture.Get<BuffDataCenter>(), "mapBuffDataList")[7].buffID == 3, "Original buff deserialization");
Check(fixture.Get<FishingBoxData>().GetString() == "1,2", "Original modern box replaces legacy");
Check(fixture.Get<SigninData>().monthlySignEvent.index == 4 && fixture.Get<SigninData>().dailySignEvent.index == 2, "Original monthly sign history");
Check(fixture.Get<TriggerPackData>().triggerPackList[0].vipLevel == 4, "Original trigger pack history");
foreach (var sample in new[]
{
("FishingChallengeData", (object)fixture.Get<FishingChallengeManager>().fishingChallengeData),
("OfferChainsChestData", (object)fixture.Get<DataCenter.OfferChainsChestManager>().OfferChainsData),
("OfferDiscoPartyManager", (object)fixture.Get<DataCenter.OfferDiscoPartyManager>().OfferDiscoPartyData),
("EventPartnerGatherData", (object)fixture.Get<DataCenter.EventPartnerGatherManager>().GatherData),
("EventCubeMeltData", (object)fixture.Get<EventCubeMeltManager>().EventData),
("epdk", (object)fixture.Get<EventPotionDataManager>().PotionData),
("estdk", (object)fixture.Get<EventScratchTicketDataManager>().ScratchTicketData)
}
)
{
var expected = JToken.Parse(raw[sample.Item1]);
if (sample.Item1 == "OfferDiscoPartyManager")
{
((JObject)expected).Remove("ChestReward");
((JObject)expected).Remove("Finished");
}
Check(ContainsPayload(JToken.FromObject(sample.Item2), expected), "Original numeric/history payload: " + sample.Item1);
}
var pinball = fixture.Get<EventPinballDataManager>();
Check(pinball.PinballData.Token == 11 && pinball.RewardList.SequenceEqual(fixture.Tables.TbEventPinballReward.DataList.Select(r => (float)r.SpinPoint)), "Original Pinball initializes reward configuration");
Check(!Field<bool>(pinball, "m_IsSimulatorMode"), "Original Pinball exits simulator mode");
Check(fixture.Events.ThanksGivingPackData.EventId == 1 && fixture.Events.ThanksGivingPackData.ChainProgress == 4 && fixture.Events.ChainPackWithProgressMigrationData.EventId == 5 && fixture.Events.ChainPackWithProgressMigrationData.ChainProgress == 8, "Two original progress keys remain independent");
var achievement = fixture.Get<AchievementDataManager>();
achievement.LoadAchievementDataToObject("{\"0\":\"7|18446744073709551615|9\"}");
Check(Field<Dictionary<cfg.ConditionType, AchievementData>>(achievement, "_achievementDatas")[(cfg.ConditionType)0].Target == ulong.MaxValue, "Original achievement preserves ulong maximum");
Check(fixture.Get<ClubService>().IsOpenClubRed == false, "Club key existence rule");
Check(fixture.Get<EventPackData>().PurchaseCount == 2, "Unknown EventPack preserves parsed data");
Check(fixture.Get<BargainPackData>().PurchaseCount == 11, "Bargain original data");
Check(fixture.Get<PiggyBankPackData>().PurchaseCount == 11, "Piggy original data");
Check(fixture.Get<IFootPrintService>().FootPrint == (EFootPrint)ulong.Parse(raw["FootPrint"]), "Original FootPrint enum conversion");
var model = fixture.Get<Script.RuntimeScript.DiggingGameManager>().DataModel;
fixture.Player.LoadPlayerData(new Dictionary<string, string>());
Check(ReferenceEquals(model, fixture.Get<Script.RuntimeScript.DiggingGameManager>().DataModel), "Digging original existing model retained");
Check(fixture.Shop.shopPackDailyBuyCount["x"] == 1 && fixture.Get<BargainPackData>().PurchaseCount == 11, "Retry missing keys retains original existing objects");
traces.Add(new { path = "full-original-and-missing-key-retry", keys = raw.Keys.ToArray(), writes = fixture.Store.Writes.ToArray(), faceCalls = fixture.Face.Calls.ToArray() });
}
using (var fixture = new Fixture())
{
var rod = fixture.Tables.TbGlobalConfig.InitRodID;
foreach (var count in new[]
{
19,
20
}
)
{
var slots = new object[]
{
9,
23UL,
0,
-4,
5,
6,
-999,
new DateTime(2026, 1, 1),
0,
8,
7UL,
0,
0,
0,
0,
0,
2,
3,
4,
99
}.Take(count).ToArray();
var raw = new Dictionary<string, string>
{
["Lv"] = "2",
["Energy"] = "10",
["EquipRodID"] = rod.ToString(),
["NewPlayerDataDic"] = JsonConvert.SerializeObject(new Dictionary<string, string> { ["LV"] = "4", ["newData"] = string.Join(",", slots), ["vipL"] = "-1,1" })
};
fixture.Player.LoadPlayerData(raw);
Check((int)fixture.Player.lv == 9 && fixture.Player.Energy == 0 && fixture.Player.equipRodID == rod, "19 and 20 slots original override/clamp/rod fallback");
}
fixture.Shop.CouponData.LoadData("2026,9,23,1,2,3,14");
Check(fixture.Shop.CouponData.RedirectId == 0 && !fixture.Shop.CouponData.IsBought, "Seven-field coupon history");
fixture.Shop.CouponData.LoadData("2026,9,23,1,2,3,14,15,1");
Check(fixture.Shop.CouponData.RedirectId == 15 && fixture.Shop.CouponData.IsBought, "Nine-field coupon history");
var compressed = fixture.Battle.BattlePassRecord.SerializeObject() + "#" + fixture.Battle.BattleTask.SerializeObject();
fixture.Battle.InitData(new Dictionary<string, string> { ["BattlePassRecordTask"] = compressed, ["BattlePassRecord"] = "bad JSON", ["BattleTask"] = "bad JSON" });
Check(fixture.Battle.BattlePassRecord != null && fixture.Battle.BattleTask != null, "Compressed BP takes precedence without parsing conflicting legacy");
var realFish = fixture.Tables.TbFishData.DataList.First(f => fixture.Tables.TbMapData.DataMap.ContainsKey(f.AreaID));
fixture.Fish.InitData(new Dictionary<string, string> { ["Map_5_" + realFish.AreaID] = JsonConvert.SerializeObject(new Dictionary<int, string> { [realFish.ID] = "0|7|2|3|True" }), ["Map_" + realFish.AreaID] = "bad legacy JSON" });
var parsedFish = Field<Dictionary<int, Dictionary<int, FishWeightData>>>(fixture.Fish, "mapFishData")[realFish.AreaID][realFish.ID];
Check(parsedFish.TotalNum == 3 && parsedFish.NewFish, "Map_5 takes precedence over broken legacy map with original weight correction");
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["GuideSaveData"] = "bad JSON", ["GuideSaveDataNew"] = "K|bad|1", ["LimitedTimeEventDic"] = "bad JSON", ["epdk"] = "", ["epbdk"] = "bad JSON", ["estdk"] = "bad JSON", ["Lv"] = "17" });
Check((int)fixture.Player.lv == 17 && fixture.Guide.GuideSaveDataDic["K"].guideSubIndex == 0, "Original local catches continue and Guide TryParse default");
Exception thrown = null;
try
{
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["IsFix"] = "invalid-bool" });
}
catch (FormatException ex)
{
thrown = ex;
}
Check(thrown != null && thrown.StackTrace.Contains("InitData"), "Original external parse exception is not Schema rejection");
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["Lv"] = "19" });
Check((int)fixture.Player.lv == 19, "Original load can retry after failure without uniform reset");
}
}
catch (Exception ex)
{
failure = ex.ToString();
}
finally
{
ZZTimeHelper.timeDiff = previousDiff;
ZZTimeHelper.LocalTimeOffsetInSec = previousOffset;
ContextAccess.Use(previousContainer);
ContextAccess.Events = previousEvents;
running = false;
}
var restored = ReferenceEquals(GContext.container, previousContainer) && ReferenceEquals(ContextAccess.Events, previousEvents) && ZZTimeHelper.timeDiff == previousDiff && ZZTimeHelper.LocalTimeOffsetInSec == previousOffset;
return Task.FromResult(JsonConvert.SerializeObject(new { success = failure == null && restored, caseId, assertions, traces, failure, restored, formalRequests = 0, baseline = "f40a816cc414fc4f30d67626d060d90fdc5eaca3 original business methods (source comparison required)", playerFabNetworkLoginExecuted = false }));
}
}
}
#endif

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 8cda3d2b981ada043af2162ca707d8bf guid: 04d2914963285d343b5a0e9503b59390
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
@@ -98,7 +99,8 @@ namespace game.PlayerDataStorage
if (mutation) return new PlayerKVResult(PlayerKVError.None, status); if (mutation) return new PlayerKVResult(PlayerKVError.None, status);
var text = await response.Content.ReadAsStringAsync(); var text = await response.Content.ReadAsStringAsync();
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
return new PlayerKVResult(PlayerKVError.None, status, ParseData(text)); var data = ParseData(text, out var updatedAtUtc);
return new PlayerKVResult(PlayerKVError.None, status, data, updatedAtUtc: updatedAtUtc);
} }
} }
} }
@@ -125,18 +127,50 @@ namespace game.PlayerDataStorage
} }
} }
static Dictionary<string, string> ParseData(string text) private static Dictionary<string, string> ParseData(string text, out Dictionary<string, DateTime> updatedAtUtc)
{ {
updatedAtUtc = null;
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
{ {
var body = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); var body = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error });
if (reader.Read() || !(body["data"] is JObject data)) throw new JsonReaderException("Invalid KV response."); if (reader.Read() || !(body["data"] is JObject data))
{
throw new JsonReaderException("Invalid KV response.");
}
var result = new Dictionary<string, string>(StringComparer.Ordinal); var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var property in data.Properties()) foreach (var property in data.Properties())
{ {
if (property.Value.Type != JTokenType.String) throw new JsonReaderException("KV value must be a string."); if (property.Value.Type != JTokenType.String)
{
throw new JsonReaderException("KV value must be a string.");
}
result.Add(property.Name, property.Value.Value<string>()); result.Add(property.Name, property.Value.Value<string>());
} }
var timestamps = body.Property("updatedAtUtc");
if (timestamps != null)
{
if (!(timestamps.Value is JObject times) || times.Count != result.Count)
{
throw new JsonReaderException("Invalid KV timestamp metadata.");
}
updatedAtUtc = new Dictionary<string, DateTime>(StringComparer.Ordinal);
var formats = new[] { "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'" };
foreach (var property in times.Properties())
{
if (!result.ContainsKey(property.Name) || property.Value.Type != JTokenType.String)
{
throw new JsonReaderException("Invalid KV timestamp entry.");
}
var value = property.Value.Value<string>();
if ((value.Length != 20 && (value.Length < 22 || value.Length > 28)) ||
!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
{
throw new JsonReaderException("KV timestamp must be UTC ISO 8601.");
}
updatedAtUtc.Add(property.Name, timestamp);
}
}
return result; return result;
} }
} }

View File

@@ -7,12 +7,9 @@ using game.Account;
namespace game.PlayerDataStorage namespace game.PlayerDataStorage
{ {
// The lease-only capability remains source-compatible with the independent 4B1 regression host. // Prebuild distinct transports with one cookie jar, then transfer each owned handler once.
// This separate capability supplies every dependency required by a complete controlled load.
internal sealed class PlayerKVGameLoadCapability internal sealed class PlayerKVGameLoadCapability
{ {
internal PlayerKVIdentitySnapshot Identity { get; }
internal PlayerKVSchemaSnapshot Snapshot { get; }
internal Uri Api { get; } internal Uri Api { get; }
internal string CacheRoot { get; } internal string CacheRoot { get; }
internal int LoginObserveMs { get; } internal int LoginObserveMs { get; }
@@ -20,47 +17,59 @@ namespace game.PlayerDataStorage
internal bool RequireExistingArchive { get; } internal bool RequireExistingArchive { get; }
internal Func<int,CancellationToken,Task> Delay { get; } internal Func<int,CancellationToken,Task> Delay { get; }
internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; } internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; }
readonly PlayerKVControlledLoadCapability auth,kv,lease; private readonly Func<CookieContainer, HttpMessageHandler> auth;
private readonly Func<CookieContainer, HttpMessageHandler> kv;
private readonly Func<CookieContainer, HttpMessageHandler> lease;
private readonly bool prepareOriginalComparison;
readonly object gate=new object(); readonly object gate=new object();
CookieContainer cookies; CookieContainer cookies;
HttpMessageHandler pendingKV,pendingLease; HttpMessageHandler pendingKV,pendingLease;
Exception cleanupFailure; Exception cleanupFailure;
readonly System.Collections.Generic.List<HttpMessageHandler> failedHandlers=new System.Collections.Generic.List<HttpMessageHandler>(); readonly System.Collections.Generic.List<HttpMessageHandler> failedHandlers=new System.Collections.Generic.List<HttpMessageHandler>();
bool claimed; bool claimed;
private PlayerKVGameLoadCapability(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,Uri api,string cacheRoot, private PlayerKVGameLoadCapability(Uri api, string cacheRoot,
int loginMs,int quitMs,Func<int,CancellationToken,Task> delay, int loginMs, int quitMs, Func<int, CancellationToken, Task> delay,
PlayerKVControlledLoadCapability auth,PlayerKVControlledLoadCapability kv,PlayerKVControlledLoadCapability lease, Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
bool requireExistingArchive=false) Func<CookieContainer, HttpMessageHandler> lease, bool requireExistingArchive = false, bool prepareOriginalComparison = false)
{ {
Identity=identity ?? throw new ArgumentNullException(nameof(identity)); Snapshot=snapshot ?? throw new ArgumentNullException(nameof(snapshot)); // Preserve the former wrappers' null-check order, diagnostic and configuration precedence.
if(api==null || !api.IsAbsoluteUri || (api.Scheme!="http" && api.Scheme!="https") || !string.IsNullOrEmpty(api.UserInfo) || this.auth = auth ?? throw new ArgumentNullException("leaseHandler");
this.kv = kv ?? throw new ArgumentNullException("leaseHandler");
this.lease = lease ?? throw new ArgumentNullException("leaseHandler");
if (api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") || !string.IsNullOrEmpty(api.UserInfo) ||
!string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) || string.IsNullOrEmpty(cacheRoot) || !string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) || string.IsNullOrEmpty(cacheRoot) ||
loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000) throw new ArgumentException("Controlled load configuration invalid."); loginMs < 1000 || loginMs > 15000 || quitMs < 1000 || quitMs > 15000)
Api=new Uri(api.AbsoluteUri.TrimEnd('/')+"/"); CacheRoot=cacheRoot; {
LoginObserveMs=loginMs; QuitObserveMs=quitMs; Delay=delay ?? Task.Delay; this.auth=auth; this.kv=kv; this.lease=lease; throw new ArgumentException("Controlled load configuration invalid.");
RequireExistingArchive=requireExistingArchive; }
Api = new Uri(api.AbsoluteUri.TrimEnd('/') + "/");
CacheRoot = cacheRoot;
LoginObserveMs = loginMs;
QuitObserveMs = quitMs;
Delay = delay ?? Task.Delay;
RequireExistingArchive = requireExistingArchive;
this.prepareOriginalComparison = prepareOriginalComparison;
} }
internal static PlayerKVGameLoadCapability CreateForGame(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot, internal static PlayerKVGameLoadCapability CreateForGame(Uri api, string cacheRoot,
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv, Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
Func<CookieContainer,HttpMessageHandler> lease) Func<CookieContainer, HttpMessageHandler> lease, bool prepareOriginalComparison = false)
{ {
if(api==null || api.AbsoluteUri!="http://s101.local:18080/") if (api == null || api.AbsoluteUri != "http://s101.local:18080/")
throw new ArgumentException("Formal KV endpoint mismatch.",nameof(api)); {
var capability=new PlayerKVGameLoadCapability(identity,snapshot,api,cacheRoot,10000,5000,null, throw new ArgumentException("Formal KV endpoint mismatch.", nameof(api));
PlayerKVControlledLoadCapability.CreateForGame(auth),PlayerKVControlledLoadCapability.CreateForGame(kv), }
PlayerKVControlledLoadCapability.CreateForGame(lease),true); var capability = new PlayerKVGameLoadCapability(api, cacheRoot, 10000, 5000, null, auth, kv, lease, true, prepareOriginalComparison);
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForGame(capability.TakeLease); capability.LeaseCapability = PlayerKVControlledLoadCapability.CreateForGame(capability.TakeLease);
return capability; return capability;
} }
#if UNITY_EDITOR #if UNITY_EDITOR
internal static PlayerKVGameLoadCapability CreateForValidation(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot, internal static PlayerKVGameLoadCapability CreateForValidation(Uri api, string cacheRoot,
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv, Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
Func<CookieContainer,HttpMessageHandler> lease,int loginMs=10000,int quitMs=5000,Func<int,CancellationToken,Task> delay=null) Func<CookieContainer, HttpMessageHandler> lease, int loginMs = 10000, int quitMs = 5000,
Func<int, CancellationToken, Task> delay = null)
{ {
var capability=new PlayerKVGameLoadCapability(identity,snapshot,api,cacheRoot,loginMs,quitMs,delay, var capability = new PlayerKVGameLoadCapability(api, cacheRoot, loginMs, quitMs, delay, auth, kv, lease);
PlayerKVControlledLoadCapability.CreateForValidation(auth),PlayerKVControlledLoadCapability.CreateForValidation(kv), capability.LeaseCapability = PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
PlayerKVControlledLoadCapability.CreateForValidation(lease));
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
return capability; return capability;
} }
#endif #endif
@@ -68,26 +77,48 @@ namespace game.PlayerDataStorage
{ {
lock(gate) { if(claimed) throw new InvalidOperationException("Controlled capability already used."); claimed=true; } lock(gate) { if(claimed) throw new InvalidOperationException("Controlled capability already used."); claimed=true; }
return new PlayerKVLoginContext(id,CacheRoot,epoch,current,Api, return new PlayerKVLoginContext(id,CacheRoot,epoch,current,Api,
()=>new S101AccountSession(Api,CreateAuthentication),()=>new PlayerKVGameSessionBridge(TakeKV)); ()=>new S101AccountSession(Api,CreateAuthentication),()=>new PlayerKVGameSessionBridge(TakeKV, prepareOriginalComparison));
} }
HttpMessageHandler CreateAuthentication(CookieContainer jar) private HttpMessageHandler CreateAuthentication(CookieContainer jar)
{ {
lock(gate) lock (gate)
{ {
if(cookies!=null) throw new InvalidOperationException("Repeated authentication factory."); if (cookies != null)
cookies=jar; HttpMessageHandler authentication=null; {
throw new InvalidOperationException("Repeated authentication factory.");
}
cookies = jar;
HttpMessageHandler authentication = null;
try try
{ {
// Validate all three returned handler graphs before the first authentication send. // Validate all three returned handler graphs before the first authentication send.
authentication=auth.CreateHandler(jar); pendingKV=kv.CreateHandler(jar); pendingLease=lease.CreateHandler(jar); authentication = PlayerKVControlledLoadCapability.CreateHandler(auth, jar, LeaseCapability.IsFormal);
if(ShareHandlerGraph(authentication,pendingKV,pendingLease)) pendingKV = PlayerKVControlledLoadCapability.CreateHandler(kv, jar, LeaseCapability.IsFormal);
pendingLease = PlayerKVControlledLoadCapability.CreateHandler(lease, jar, LeaseCapability.IsFormal);
if (ShareHandlerGraph(authentication, pendingKV, pendingLease))
{
throw new InvalidOperationException("Distinct transport ownership required."); throw new InvalidOperationException("Distinct transport ownership required.");
}
return authentication; return authentication;
} }
catch catch
{ {
try { authentication?.Dispose(); } catch(Exception ex) { cleanupFailure=ex; failedHandlers.Add(authentication); } try
try { CloseUnused(); } catch { } {
authentication?.Dispose();
}
catch (Exception ex)
{
cleanupFailure = ex;
failedHandlers.Add(authentication);
}
try
{
CloseUnused();
}
catch
{
}
throw; throw;
} }
} }

View File

@@ -1,969 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
namespace game.PlayerDataStorage
{
internal enum PlayerKVSchemaVerdict { Valid, EmptyNew, EmptyExisting, MissingCore, Malformed, ConflictingRepresentations, UnsupportedSchema, Stale }
internal sealed class PlayerKVProgressPackPair
{
internal PlayerKVProgressPackState Current { get; }
internal PlayerKVProgressPackState Migration { get; }
internal PlayerKVProgressPackPair(PlayerKVProgressPackState current,PlayerKVProgressPackState migration)
{ Current=current; Migration=migration; }
}
internal sealed class PlayerKVDecodedSave
{
readonly object[] modules;
internal string PlayerId { get; }
internal long Epoch { get; }
internal string ConfigurationVersion { get; }
internal IReadOnlyDictionary<string,string> Raw { get; }
internal int ModuleCount => modules.Length;
internal int UnconsumedTopLevelCount { get; }
internal int UnconsumedModernCount { get; }
internal int UnconsumedCount => UnconsumedTopLevelCount+UnconsumedModernCount;
internal PlayerKVDecodedSave(string playerId,long epoch,string version,IReadOnlyDictionary<string,string> raw,object[] modules,int unconsumedTop=0,int unconsumedModern=0)
{
if(modules==null || modules.Length!=31 || modules.Any(m=>m==null)) throw new ArgumentException("Complete decoded save required.");
PlayerId=playerId; Epoch=epoch; ConfigurationVersion=version;
Raw=new ReadOnlyDictionary<string,string>(raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal));
this.modules=(object[])modules.Clone();
UnconsumedTopLevelCount=unconsumedTop; UnconsumedModernCount=unconsumedModern;
}
internal object Module(int id) { if(id<1 || id>31) throw new ArgumentOutOfRangeException(nameof(id)); return modules[id-1]; }
internal T Module<T>(int id) where T:class => Module(id) as T ?? throw new InvalidOperationException("Module state mismatch.");
}
internal sealed class PlayerKVSchemaResult
{
internal PlayerKVSchemaVerdict Verdict { get; }
internal PlayerKVSchemaDiagnostics Diagnostics { get; }
internal PlayerKVDecodedSave Data { get; }
internal PlayerKVSchemaResult(PlayerKVSchemaVerdict verdict,PlayerKVSchemaDiagnostics diagnostics,PlayerKVDecodedSave data=null)
{ Verdict=verdict; Diagnostics=diagnostics; Data=verdict==PlayerKVSchemaVerdict.Valid ? data : null; }
}
internal enum PlayerKVIdentityChannel { hwGoogle, hwFacebook, hwGuest, hwVkid, Apple, none }
internal sealed class PlayerKVIdentitySnapshot
{
internal string UserId { get; }
internal bool NewlyCreated { get; }
internal PlayerKVIdentityChannel Channel { get; }
internal IReadOnlyList<PlayerKVIdentityChannel> Linked { get; }
internal PlayerKVIdentitySnapshot(string userId,bool newlyCreated,PlayerKVIdentityChannel channel,IEnumerable<PlayerKVIdentityChannel> linked)
{
if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel) || linked==null) throw new ArgumentException("Identity snapshot required.");
var copy=linked.Distinct().ToList();
if(copy.Any(c=>!Enum.IsDefined(typeof(PlayerKVIdentityChannel),c))) throw new ArgumentException("Unknown identity channel.");
UserId=userId; NewlyCreated=newlyCreated; Channel=channel; Linked=copy.AsReadOnly();
}
}
internal sealed class PlayerKVLoginState
{
internal int MMTest { get; }
internal int TapJoyBalance { get; }
internal IReadOnlyDictionary<string,string> AccountUrls { get; }
internal IReadOnlyDictionary<string,string> Normalizations { get; }
internal IReadOnlyList<PlayerKVIdentityChannel> Linked { get; }
internal PlayerKVIdentityChannel Channel { get; }
internal PlayerKVLoginState(int mm,int balance,IDictionary<string,string> urls,IDictionary<string,string> normalizations,
IEnumerable<PlayerKVIdentityChannel> linked,PlayerKVIdentityChannel channel)
{
MMTest=mm; TapJoyBalance=balance;
AccountUrls=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(urls,StringComparer.Ordinal));
Normalizations=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(normalizations,StringComparer.Ordinal));
Linked=linked.Distinct().ToList().AsReadOnly(); Channel=channel;
}
}
internal sealed class PlayerKVPartnerBot
{
internal string Id { get; }
internal int ScorePlayer { get; }
internal int ScoreBot { get; }
internal int Grade { get; }
internal int TargetScore { get; }
internal DateTime WakeStartTime { get; }
internal int WakingSeconds { get; }
internal PlayerKVPartnerBot(string id,int player,int bot,int grade,int target,DateTime wake,int seconds)
{ Id=id; ScorePlayer=player; ScoreBot=bot; Grade=grade; TargetScore=target; WakeStartTime=wake; WakingSeconds=seconds; }
}
internal sealed class PlayerKVPartnerState
{
internal bool Present { get; }
internal bool FirstOpen { get; }
internal int EventId { get; }
internal DateTime StartTime { get; }
internal DateTime EndTime { get; }
internal int CurrentPoints { get; }
internal int CurrentMiningProgress { get; }
internal int Token { get; }
internal int InvitedCount { get; }
internal int TicketCount { get; }
internal IReadOnlyList<IReadOnlyList<int>> CompletedNodesList { get; }
internal IReadOnlyList<(int myScore,int FriendScore)> BuilderPartnerScore { get; }
internal float BonusPercent { get; }
internal DateTime BonusExpireUtc { get; }
internal int FreeSpinNum { get; }
internal IReadOnlyDictionary<int,PlayerKVPartnerBot> Bots { get; }
internal bool IsRecvFinalReward { get; }
internal IReadOnlyList<bool> UIFinishedPartners { get; }
internal bool IsFinished { get; }
internal int SpinState { get; }
internal float InflationRate { get; }
internal bool IsEnableAutoMatch { get; }
internal DateTime LastAutoMatchTime { get; }
internal PlayerKVPartnerState(bool present,bool firstOpen,int eventId,DateTime start,DateTime end,
int points,int progress,int token,int invited,int tickets,IEnumerable<IEnumerable<int>> nodes,
IEnumerable<(int,int)> scores,float bonus,DateTime expires,int free,IDictionary<int,PlayerKVPartnerBot> bots,
bool reward,IEnumerable<bool> ui,bool finished,int spin,float inflation,bool autoMatch,DateTime lastMatch)
{
Present=present; FirstOpen=firstOpen; EventId=eventId; StartTime=start; EndTime=end;
CurrentPoints=points; CurrentMiningProgress=progress; Token=token; InvitedCount=invited; TicketCount=tickets;
CompletedNodesList=nodes.Select(n=>(IReadOnlyList<int>)new List<int>(n).AsReadOnly()).ToList().AsReadOnly();
BuilderPartnerScore=new List<(int,int)>(scores).AsReadOnly(); BonusPercent=bonus; BonusExpireUtc=expires;
FreeSpinNum=free; Bots=new ReadOnlyDictionary<int,PlayerKVPartnerBot>(new Dictionary<int,PlayerKVPartnerBot>(bots));
IsRecvFinalReward=reward; UIFinishedPartners=new List<bool>(ui).AsReadOnly(); IsFinished=finished;
SpinState=spin; InflationRate=inflation; IsEnableAutoMatch=autoMatch; LastAutoMatchTime=lastMatch;
}
internal static PlayerKVPartnerState Missing() => new PlayerKVPartnerState(false,false,0,default,default,
0,0,0,0,0,Array.Empty<int[]>(),Array.Empty<(int,int)>(),0,default,0,new Dictionary<int,PlayerKVPartnerBot>(),
false,Array.Empty<bool>(),false,0,0,false,default);
}
internal enum PlayerKVSchemaError { Malformed, MissingCore, ConflictingRepresentations, UnsupportedSchema }
internal sealed class PlayerKVSchemaException : Exception
{
internal PlayerKVSchemaError Code { get; }
internal PlayerKVSchemaException(PlayerKVSchemaError code = PlayerKVSchemaError.Malformed)
: base("KV schema rejected.") { Code = code; }
}
internal sealed class PlayerKVSchemaDetail
{
internal int Module { get; }
internal int Field { get; }
internal PlayerKVSchemaError Code { get; }
internal PlayerKVSchemaDetail(int module, int field, PlayerKVSchemaError code)
{ Module = module; Field = field; Code = code; }
}
// Values, save keys and exception messages are deliberately absent from diagnostic output.
internal sealed class PlayerKVSchemaDiagnostics
{
readonly List<PlayerKVSchemaDetail> details = new List<PlayerKVSchemaDetail>();
readonly SortedDictionary<int, Dictionary<PlayerKVSchemaError, int>> counts =
new SortedDictionary<int, Dictionary<PlayerKVSchemaError, int>>();
internal int TotalErrors { get; private set; }
internal int OmittedDetails => TotalErrors - details.Count;
internal IReadOnlyList<PlayerKVSchemaDetail> Details => details.AsReadOnly();
internal int Count(int module, PlayerKVSchemaError code) =>
counts.TryGetValue(module, out var codes) && codes.TryGetValue(code, out var count) ? count : 0;
internal void Add(int module, int field, PlayerKVSchemaError code)
{
if (module < 1 || module > 31 || field < 0) throw new ArgumentOutOfRangeException();
TotalErrors++;
if (!counts.TryGetValue(module, out var codes)) counts.Add(module, codes = new Dictionary<PlayerKVSchemaError, int>());
codes[code] = Count(module, code) + 1;
// Only retain 32 items, even when callers visit representations in different orders.
var item = new PlayerKVSchemaDetail(module, field, code);
int index = details.FindIndex(x => x.Module > module || (x.Module == module && x.Field > field));
if (index < 0) index = details.Count;
if (index < 32) details.Insert(index, item);
if (details.Count > 32) details.RemoveAt(32);
}
internal T Capture<T>(int module, int field, Func<T> parse)
{
try { return parse(); }
catch (PlayerKVSchemaException ex) { Add(module, field, ex.Code); return default; }
}
}
// Captured copies only: decoding never resolves Unity services or reads current culture/time.
internal sealed class PlayerKVSchemaSnapshot
{
readonly HashSet<int> rods, maps;
readonly IReadOnlyDictionary<int, int> packTypes;
readonly IReadOnlyDictionary<int, PlayerKVEventConfig> events;
readonly IReadOnlyDictionary<string, int> conditions;
readonly HashSet<int> conditionValues;
readonly IReadOnlyDictionary<int,PlayerKVFishConfig> fish;
readonly IReadOnlyDictionary<int,PlayerKVFishCardConfig> cards;
internal string Version { get; }
internal CultureInfo Culture { get; }
internal DateTime NowUtc { get; }
internal DateTime NowGame { get; }
internal int InitWheelTicket { get; }
internal PlayerKVSchemaSnapshot(string version, IEnumerable<int> rods, IEnumerable<int> maps,
IDictionary<string, int> conditions, CultureInfo culture, DateTime nowUtc, int initWheelTicket,
IDictionary<int, int> packTypes = null, IDictionary<int, PlayerKVEventConfig> events = null, int globalTimeOffset = 0,
IDictionary<int,PlayerKVFishConfig> fish = null, IDictionary<int,PlayerKVFishCardConfig> cards = null)
{
if (string.IsNullOrEmpty(version) || rods == null || maps == null || conditions == null || culture == null)
throw new ArgumentException("Schema snapshot required.");
Version = version; this.rods = new HashSet<int>(rods); this.maps = new HashSet<int>(maps);
this.packTypes = new ReadOnlyDictionary<int, int>(new Dictionary<int, int>(packTypes ?? new Dictionary<int, int>()));
this.events = new ReadOnlyDictionary<int, PlayerKVEventConfig>(new Dictionary<int, PlayerKVEventConfig>(events ?? new Dictionary<int, PlayerKVEventConfig>()));
this.conditions = new ReadOnlyDictionary<string, int>(new Dictionary<string, int>(conditions, StringComparer.Ordinal));
conditionValues = new HashSet<int>(conditions.Values);
Culture = CultureInfo.ReadOnly((CultureInfo)culture.Clone()); NowUtc = nowUtc; InitWheelTicket = initWheelTicket;
NowGame = nowUtc.AddHours(-(double)globalTimeOffset);
this.fish=new ReadOnlyDictionary<int,PlayerKVFishConfig>(new Dictionary<int,PlayerKVFishConfig>(fish ?? new Dictionary<int,PlayerKVFishConfig>()));
this.cards=new ReadOnlyDictionary<int,PlayerKVFishCardConfig>(new Dictionary<int,PlayerKVFishCardConfig>(cards ?? new Dictionary<int,PlayerKVFishCardConfig>()));
}
internal PlayerKVFishConfig Fish(int id) => fish.TryGetValue(id,out var value) ? value : null;
internal PlayerKVFishCardConfig Card(int id) => cards.TryGetValue(id,out var value) ? value : null;
internal bool HasRod(int id) => rods.Contains(id);
internal bool HasPack(int id) => packTypes.ContainsKey(id);
internal PlayerKVEventConfig Event(int id) => events.TryGetValue(id,out var value) ? value : null;
internal bool HasPackType(int id, int type) => packTypes.TryGetValue(id, out var actual) && actual == type;
internal bool HasMap(int id) => maps.Contains(id);
internal int Condition(string text)
{
if (conditions.TryGetValue(text, out var named)) return named;
if (int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var numeric)
&& conditionValues.Contains(numeric)) return numeric;
throw new PlayerKVSchemaException();
}
}
internal sealed class PlayerKVCoreState
{
readonly object[] slots;
internal PlayerKVCoreState(object[] slots) { this.slots = (object[])slots.Clone(); }
internal int Integer(int slot) => (int)slots[slot];
internal ulong Unsigned(int slot) => (ulong)slots[slot];
internal DateTime EnergyTime => (DateTime)slots[7];
internal bool Matches(int slot, object value) => Equals(slots[slot], value);
}
internal sealed class PlayerKVAchievementValue
{
internal int Id { get; }
internal ulong Target { get; }
internal int Day { get; }
internal PlayerKVAchievementValue(int id, ulong target, int day) { Id = id; Target = target; Day = day; }
internal bool Same(PlayerKVAchievementValue other) => Id == other.Id && Target == other.Target && Day == other.Day;
}
internal sealed class PlayerKVAchievementState
{
internal IReadOnlyDictionary<int, PlayerKVAchievementValue> Values { get; }
internal PlayerKVAchievementState(IDictionary<int, PlayerKVAchievementValue> values)
{ Values = new ReadOnlyDictionary<int, PlayerKVAchievementValue>(new Dictionary<int, PlayerKVAchievementValue>(values)); }
}
internal sealed class PlayerKVPiggyBankState
{
internal bool IsPresent { get; }
internal int currentEventID { get; }
internal int purchaseCount { get; }
internal int VIPLvlWhenEventActivate { get; }
internal int savingProgress { get; }
internal int redirectID { get; }
internal bool doNeedTrigger { get; }
internal bool isTriggered { get; }
internal float inflation { get; }
internal PlayerKVPiggyBankState(bool present, int currentEventID, int purchaseCount, int VIPLvlWhenEventActivate, int savingProgress, int redirectID, bool doNeedTrigger, bool isTriggered, float inflation)
{
IsPresent = present;
this.currentEventID = currentEventID;
this.purchaseCount = purchaseCount;
this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate;
this.savingProgress = savingProgress;
this.redirectID = redirectID;
this.doNeedTrigger = doNeedTrigger;
this.isTriggered = isTriggered;
this.inflation = inflation;
}
internal static PlayerKVPiggyBankState Missing() => new PlayerKVPiggyBankState(false, 0, 0, 0, 0, 0, false, false, 0);
}
internal sealed class PlayerKVBargainState
{
internal bool IsPresent { get; }
internal int currentEventID { get; }
internal int redirectID { get; }
internal int VIPLvlWhenEventActivate { get; }
internal int purchaseCount { get; }
internal int savingProgress { get; }
internal int discountLvl { get; }
internal int visualProgress { get; }
internal int visualDiscountLvl { get; }
internal int rodID { get; }
internal int lastPackIDBought { get; }
internal int packID { get; }
internal bool doNeedTrigger { get; }
internal bool isTriggered { get; }
internal float inflation { get; }
internal IReadOnlyList<int> selections { get; }
internal PlayerKVBargainState(bool present, int currentEventID, int redirectID, int VIPLvlWhenEventActivate, int purchaseCount, int savingProgress, int discountLvl, int visualProgress, int visualDiscountLvl, int rodID, int lastPackIDBought, int packID, bool doNeedTrigger, bool isTriggered, float inflation, IEnumerable<int> selections)
{
IsPresent = present;
this.currentEventID = currentEventID;
this.redirectID = redirectID;
this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate;
this.purchaseCount = purchaseCount;
this.savingProgress = savingProgress;
this.discountLvl = discountLvl;
this.visualProgress = visualProgress;
this.visualDiscountLvl = visualDiscountLvl;
this.rodID = rodID;
this.lastPackIDBought = lastPackIDBought;
this.packID = packID;
this.doNeedTrigger = doNeedTrigger;
this.isTriggered = isTriggered;
this.inflation = inflation;
this.selections = selections == null ? null : new List<int>(selections).AsReadOnly();
}
internal static PlayerKVBargainState Missing() => new PlayerKVBargainState(false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, false, 0, Array.Empty<int>());
}
internal sealed class PlayerKVShootingRangeState
{
internal bool IsPresent { get; }
internal int EventId { get; }
internal int CycleId { get; }
internal int RedirectId { get; }
internal int AmmoCount { get; }
internal int CurrentStageIdx { get; }
internal int ChainProgress { get; }
internal long RewardFlag { get; }
internal long TargetFlag { get; }
internal bool HasBeenOpened { get; }
internal float Inflation { get; }
internal PlayerKVShootingRangeState(bool present, int EventId, int CycleId, int RedirectId, int AmmoCount, int CurrentStageIdx, int ChainProgress, long RewardFlag, long TargetFlag, bool HasBeenOpened, float Inflation)
{
IsPresent = present;
this.EventId = EventId;
this.CycleId = CycleId;
this.RedirectId = RedirectId;
this.AmmoCount = AmmoCount;
this.CurrentStageIdx = CurrentStageIdx;
this.ChainProgress = ChainProgress;
this.RewardFlag = RewardFlag;
this.TargetFlag = TargetFlag;
this.HasBeenOpened = HasBeenOpened;
this.Inflation = Inflation;
}
internal static PlayerKVShootingRangeState Missing() => new PlayerKVShootingRangeState(false, 0, 0, 0, 0, 0, 0, 0, 0, false, 0);
}
internal sealed class PlayerKVBuffValue
{
internal int SortID { get; }
internal int sourceID { get; }
internal int buffID { get; }
internal bool isEnd { get; }
internal DateTime buffEndTime { get; }
internal PlayerKVBuffValue(int SortID,int sourceID,int buffID,bool isEnd,DateTime buffEndTime)
{ this.SortID=SortID; this.sourceID=sourceID; this.buffID=buffID; this.isEnd=isEnd; this.buffEndTime=buffEndTime; }
}
internal sealed class PlayerKVTriggerValue
{
internal int ID { get; }
internal int index { get; }
internal int lastID { get; }
internal int vipLevel { get; }
internal int mapID { get; }
internal string time { get; }
internal PlayerKVTriggerValue(int ID,int index,int lastID,int vipLevel,int mapID,string time)
{ this.ID=ID; this.index=index; this.lastID=lastID; this.vipLevel=vipLevel; this.mapID=mapID; this.time=time; }
}
internal sealed class PlayerKVGiftVipValue
{
internal int oldVipLevel { get; }
internal int curVipLevel { get; }
internal int timer { get; }
internal PlayerKVGiftVipValue(int oldVipLevel,int curVipLevel,int timer)
{ this.oldVipLevel=oldVipLevel; this.curVipLevel=curVipLevel; this.timer=timer; }
}
internal sealed class PlayerKVFixedTimeValue
{
internal int id { get; }
internal string endTime { get; }
internal PlayerKVFixedTimeValue(int id,string endTime)
{ this.id=id; this.endTime=endTime; }
}
internal sealed class PlayerKVBuffState
{
internal IReadOnlyDictionary<int,PlayerKVBuffValue> Map { get; }
internal IReadOnlyDictionary<int,PlayerKVBuffValue> Global { get; }
internal IReadOnlyList<PlayerKVBuffValue> Target { get; }
internal PlayerKVBuffValue Daily { get; }
internal PlayerKVBuffState(IDictionary<int,PlayerKVBuffValue> map,IDictionary<int,PlayerKVBuffValue> global,
IEnumerable<PlayerKVBuffValue> target,PlayerKVBuffValue daily)
{
Map=new ReadOnlyDictionary<int,PlayerKVBuffValue>(new Dictionary<int,PlayerKVBuffValue>(map));
Global=new ReadOnlyDictionary<int,PlayerKVBuffValue>(new Dictionary<int,PlayerKVBuffValue>(global));
Target=new List<PlayerKVBuffValue>(target).AsReadOnly(); Daily=daily;
}
}
internal sealed class PlayerKVTriggerState
{
internal IReadOnlyList<PlayerKVTriggerValue> Packs { get; }
internal IReadOnlyDictionary<string,PlayerKVGiftVipValue> Gifts { get; }
internal IReadOnlyDictionary<int,PlayerKVFixedTimeValue> Fixed { get; }
internal PlayerKVTriggerState(IEnumerable<PlayerKVTriggerValue> packs,IDictionary<string,PlayerKVGiftVipValue> gifts,
IDictionary<int,PlayerKVFixedTimeValue> fixedValues)
{
Packs=new List<PlayerKVTriggerValue>(packs).AsReadOnly();
Gifts=new ReadOnlyDictionary<string,PlayerKVGiftVipValue>(new Dictionary<string,PlayerKVGiftVipValue>(gifts,StringComparer.Ordinal));
Fixed=new ReadOnlyDictionary<int,PlayerKVFixedTimeValue>(new Dictionary<int,PlayerKVFixedTimeValue>(fixedValues));
}
}
internal sealed class PlayerKVPlayerState
{
internal PlayerKVCoreState Core { get; }
internal IReadOnlyDictionary<string,string> Modern { get; }
internal IReadOnlyDictionary<int,int> VipScores { get; }
internal IReadOnlyList<int> NewRods { get; }
internal int Season { get; }
internal bool Appraise { get; }
internal string Building { get; }
internal PlayerKVPlayerState(PlayerKVCoreState core,IDictionary<string,string> modern,IDictionary<int,int> scores,
IEnumerable<int> rods,int season,bool appraise,string building)
{
Core=core; Modern=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(modern,StringComparer.Ordinal));
VipScores=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(scores)); NewRods=new List<int>(rods).AsReadOnly();
Season=season; Appraise=appraise; Building=building;
}
}
internal sealed class PlayerKVPotionItem
{
internal string Name { get; }
internal int Index { get; }
internal bool IsOver { get; }
internal int DataType { get; }
internal IReadOnlyList<int> Colors { get; }
internal IReadOnlyList<int> Shelves { get; }
internal IReadOnlyList<int> Grids { get; }
internal IReadOnlyList<int> FromDisplay { get; }
internal PlayerKVPotionItem(string name,int index,bool over,int type,IEnumerable<int> colors,
IEnumerable<int> shelves,IEnumerable<int> grids,IEnumerable<int> fromDisplay)
{
Name=name; Index=index; IsOver=over; DataType=type;
Colors=new List<int>(colors).AsReadOnly(); Shelves=new List<int>(shelves).AsReadOnly();
Grids=new List<int>(grids).AsReadOnly(); FromDisplay=new List<int>(fromDisplay).AsReadOnly();
}
}
internal sealed class PlayerKVPotionState
{
internal bool IsPresent { get; }
internal int EventID { get; }
internal int PotionID { get; }
internal int StageID { get; }
internal int Stage1TotalLevel { get; }
internal int Stage2TotalLevel { get; }
internal int LevelID { get; }
internal int LevelIndex { get; }
internal int CorrectNum { get; }
internal int SubmitNum { get; }
internal int Token { get; }
internal int ConsumedToken { get; }
internal int Rule { get; }
internal int PreEventID { get; }
internal int PrePotionID { get; }
internal int PreStageID { get; }
internal int PreLevelID { get; }
internal int Round { get; }
internal int FirstPlayedEventId { get; }
internal bool IsOver { get; }
internal string Name { get; }
internal float InflationRate { get; }
internal PlayerKVPotionItem LevelTargetData { get; }
internal PlayerKVPotionItem LevelDisplayData { get; }
internal IReadOnlyList<PlayerKVPotionItem> LevelProcessedList { get; }
internal PlayerKVPotionState(bool present,int EventID,int PotionID,int StageID,int Stage1TotalLevel,int Stage2TotalLevel,int LevelID,int LevelIndex,int CorrectNum,int SubmitNum,int Token,int ConsumedToken,int Rule,int PreEventID,int PrePotionID,int PreStageID,int PreLevelID,int Round,int FirstPlayedEventId,bool IsOver,string Name,float InflationRate,PlayerKVPotionItem LevelTargetData,PlayerKVPotionItem LevelDisplayData,List<PlayerKVPotionItem> LevelProcessedList)
{
IsPresent=present;
this.EventID=EventID;
this.PotionID=PotionID;
this.StageID=StageID;
this.Stage1TotalLevel=Stage1TotalLevel;
this.Stage2TotalLevel=Stage2TotalLevel;
this.LevelID=LevelID;
this.LevelIndex=LevelIndex;
this.CorrectNum=CorrectNum;
this.SubmitNum=SubmitNum;
this.Token=Token;
this.ConsumedToken=ConsumedToken;
this.Rule=Rule;
this.PreEventID=PreEventID;
this.PrePotionID=PrePotionID;
this.PreStageID=PreStageID;
this.PreLevelID=PreLevelID;
this.Round=Round;
this.FirstPlayedEventId=FirstPlayedEventId;
this.IsOver=IsOver;
this.Name=Name;
this.InflationRate=InflationRate;
this.LevelTargetData=LevelTargetData;
this.LevelDisplayData=LevelDisplayData;
this.LevelProcessedList=new List<PlayerKVPotionItem>(LevelProcessedList).AsReadOnly();
}
internal static PlayerKVPotionState Missing() => new PlayerKVPotionState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List<PlayerKVPotionItem>());
}
internal sealed class PlayerKVPassRecord
{
internal int ID { get; }
internal int Level { get; }
internal int Exp { get; }
internal int BuyVipToastIndex { get; }
internal int IsUnlockVip { get; }
internal IReadOnlyList<int> Normal { get; }
internal IReadOnlyList<int> Vip { get; }
internal IReadOnlyList<int> LastDrops { get; }
internal float Inflation { get; }
internal PlayerKVPassRecord(int id,int level,int exp,int toast,int unlock,IEnumerable<int> normal,
IEnumerable<int> vip,IEnumerable<int> lastDrops,float inflation)
{
ID=id; Level=level; Exp=exp; BuyVipToastIndex=toast; IsUnlockVip=unlock;
Normal=new List<int>(normal).AsReadOnly(); Vip=new List<int>(vip).AsReadOnly();
LastDrops=new List<int>(lastDrops).AsReadOnly(); Inflation=inflation;
}
internal bool CommonEquals(PlayerKVPassRecord other) => ID==other.ID && Level==other.Level && Exp==other.Exp &&
BuyVipToastIndex==other.BuyVipToastIndex && IsUnlockVip==other.IsUnlockVip && Normal.SequenceEqual(other.Normal) && Vip.SequenceEqual(other.Vip);
internal static PlayerKVPassRecord Empty() => new PlayerKVPassRecord(0,1,0,0,0,Array.Empty<int>(),Array.Empty<int>(),Array.Empty<int>(),0);
}
internal sealed class PlayerKVPassTask
{
internal IReadOnlyDictionary<int,int> Daily { get; }
internal IReadOnlyDictionary<int,int> Weekly { get; }
internal IReadOnlyList<int> LoginDays { get; }
internal DateTime DailyEnd { get; }
internal DateTime WeeklyEnd { get; }
internal PlayerKVPassTask(IDictionary<int,int> daily,IDictionary<int,int> weekly,IEnumerable<int> days,DateTime dailyEnd,DateTime weeklyEnd)
{
Daily=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(daily)); Weekly=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(weekly));
LoginDays=new List<int>(days).AsReadOnly(); DailyEnd=dailyEnd; WeeklyEnd=weeklyEnd;
}
internal bool Same(PlayerKVPassTask other) => Daily.Count==other.Daily.Count && Weekly.Count==other.Weekly.Count &&
Daily.All(p=>other.Daily.TryGetValue(p.Key,out var value) && value==p.Value) &&
Weekly.All(p=>other.Weekly.TryGetValue(p.Key,out var value) && value==p.Value) &&
LoginDays.SequenceEqual(other.LoginDays) && DailyEnd==other.DailyEnd && WeeklyEnd==other.WeeklyEnd;
internal static PlayerKVPassTask Empty() => new PlayerKVPassTask(new Dictionary<int,int>(),new Dictionary<int,int>(),Array.Empty<int>(),default,default);
}
internal sealed class PlayerKVBattlePassState
{
internal PlayerKVPassRecord Record { get; }
internal PlayerKVPassTask Task { get; }
internal PlayerKVBattlePassState(PlayerKVPassRecord record,PlayerKVPassTask task) { Record=record; Task=task; }
}
internal sealed class PlayerKVCollectValue
{
internal int ItemId { get; }
internal int Count { get; }
internal int MaxCount { get; }
internal int ItemTrueId { get; }
internal PlayerKVCollectValue(int itemId,int count,int maxCount,int trueId)
{ ItemId=itemId; Count=count; MaxCount=maxCount; ItemTrueId=trueId; }
}
internal sealed class PlayerKVCubeState
{
internal bool IsPresent { get; }
internal int TicketCount { get; }
internal bool FirstOpen { get; }
internal bool IsFinished { get; }
internal int EventId { get; }
internal bool IsInGuide { get; }
internal DateTime StartTime { get; }
internal DateTime EndTime { get; }
internal int CurrentStagetId { get; }
internal IReadOnlyList<PlayerKVCollectValue> CollectItems { get; }
internal IReadOnlyList<int> BoardData { get; }
internal int Width { get; }
internal int Height { get; }
internal IReadOnlyList<int> CollectRewardsId { get; }
internal bool IsFinalRewardGet { get; }
internal IReadOnlyList<IReadOnlyList<int>> PieceDatas { get; }
internal int Cycle { get; }
internal float InflationRate { get; }
internal PlayerKVCubeState(bool present,int ticketCount,bool firstOpen,bool finished,int eventId,bool guide,
DateTime start,DateTime end,int stage,IEnumerable<PlayerKVCollectValue> items,IEnumerable<int> board,
int width,int height,IEnumerable<int> rewards,bool final,IEnumerable<IEnumerable<int>> pieces,int cycle,float inflation)
{
IsPresent=present; TicketCount=ticketCount; FirstOpen=firstOpen; IsFinished=finished; EventId=eventId; IsInGuide=guide;
StartTime=start; EndTime=end; CurrentStagetId=stage; CollectItems=new List<PlayerKVCollectValue>(items).AsReadOnly();
BoardData=new List<int>(board).AsReadOnly(); Width=width; Height=height; CollectRewardsId=new List<int>(rewards).AsReadOnly();
IsFinalRewardGet=final; PieceDatas=pieces.Select(p=>(IReadOnlyList<int>)new List<int>(p).AsReadOnly()).ToList().AsReadOnly();
Cycle=cycle; InflationRate=inflation;
}
internal static PlayerKVCubeState Missing() => new PlayerKVCubeState(false,0,false,false,0,false,default,default,0,
Array.Empty<PlayerKVCollectValue>(),Array.Empty<int>(),0,0,Array.Empty<int>(),false,Array.Empty<IEnumerable<int>>(),0,0);
}
internal sealed class PlayerKVPinataState
{
internal int EventID { get; }
internal int PinataMainID { get; }
internal int PurchasedCount { get; }
internal PlayerKVPinataState(int eventId,int mainId,int count) { EventID=eventId; PinataMainID=mainId; PurchasedCount=count; }
}
internal sealed class PlayerKVEmptyModuleState
{
internal static readonly PlayerKVEmptyModuleState Instance=new PlayerKVEmptyModuleState();
private PlayerKVEmptyModuleState() { }
}
internal sealed class PlayerKVStampValue
{
internal int EventScratchCardID { get; }
internal int Count { get; }
internal PlayerKVStampValue(int id,int count) { EventScratchCardID=id; Count=count; }
}
internal sealed class PlayerKVChallengeState
{
internal bool IsPresent { get; }
/// <summary>Internal in-memory representation of a present JSON null; not serialized to the server.</summary>
internal bool IsExplicitNull { get; }
internal bool FirstOpen { get; }
internal int EventId { get; }
internal int RankTierLevel { get; }
internal int Finished { get; }
internal int RedirectID { get; }
internal int Score { get; }
internal int ProcessedScore { get; }
internal int step { get; }
internal int uiStep { get; }
internal int index { get; }
internal DateTime StartTime { get; }
internal DateTime EndTime { get; }
internal IReadOnlyList<int> eliminateNumber { get; }
internal IReadOnlyList<int> robotAccountList { get; }
internal float InflationRate { get; }
internal PlayerKVChallengeState(bool present,bool FirstOpen,int EventId,int RankTierLevel,int Finished,int RedirectID,int Score,int ProcessedScore,int step,int uiStep,int index,DateTime StartTime,DateTime EndTime,List<int> eliminateNumber,List<int> robotAccountList,float InflationRate,bool explicitNull=false)
{
if (explicitNull && !present) throw new ArgumentException("An explicit null must have a present key.", nameof(explicitNull));
IsPresent=present;
IsExplicitNull=explicitNull;
this.FirstOpen=FirstOpen;
this.EventId=EventId;
this.RankTierLevel=RankTierLevel;
this.Finished=Finished;
this.RedirectID=RedirectID;
this.Score=Score;
this.ProcessedScore=ProcessedScore;
this.step=step;
this.uiStep=uiStep;
this.index=index;
this.StartTime=StartTime;
this.EndTime=EndTime;
this.eliminateNumber=new List<int>(eliminateNumber).AsReadOnly();
this.robotAccountList=new List<int>(robotAccountList).AsReadOnly();
this.InflationRate=InflationRate;
}
internal static PlayerKVChallengeState Missing() => new PlayerKVChallengeState(false,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new List<int>(),default);
/// <summary>Creates an internal empty-state marker, not a server serialization payload.</summary>
internal static PlayerKVChallengeState ExplicitNull() => new PlayerKVChallengeState(true,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new List<int>(),default,true);
}
internal sealed class PlayerKVPinballState
{
internal bool IsPresent { get; }
internal int EventID { get; }
internal int PinballMainID { get; }
internal int SpinPoint { get; }
internal int Scores { get; }
internal int Multiple { get; }
internal int Token { get; }
internal int ConsumedToken { get; }
internal int RewardIndex { get; }
internal float Force { get; }
internal float LuckyValue { get; }
internal float InflationRate { get; }
internal PlayerKVPinballState(bool present,int EventID,int PinballMainID,int SpinPoint,int Scores,int Multiple,int Token,int ConsumedToken,int RewardIndex,float Force,float LuckyValue,float InflationRate)
{
IsPresent=present;
this.EventID=EventID;
this.PinballMainID=PinballMainID;
this.SpinPoint=SpinPoint;
this.Scores=Scores;
this.Multiple=Multiple;
this.Token=Token;
this.ConsumedToken=ConsumedToken;
this.RewardIndex=RewardIndex;
this.Force=Force;
this.LuckyValue=LuckyValue;
this.InflationRate=InflationRate;
}
internal static PlayerKVPinballState Missing() => new PlayerKVPinballState(false,default,default,default,default,default,default,default,default,default,default,default);
}
internal sealed class PlayerKVScratchState
{
internal bool IsPresent { get; }
internal int EventID { get; }
internal int ScratchMainID { get; }
internal int ChainPackID { get; }
internal int Token { get; }
internal int ConsumedToken { get; }
internal int RewardTaskConsumedToken { get; }
internal int EventScratchRewardTaskID { get; }
internal int EventScratchCardID { get; }
internal int TotalConsumedToken { get; }
internal int ChainPackProgress { get; }
internal int magIndex { get; }
internal bool IsOver { get; }
internal float LuckyValue { get; }
internal float InflationRate { get; }
internal IReadOnlyList<int> StampRewardList { get; }
internal IReadOnlyDictionary<int,PlayerKVStampValue> StampRewardDict { get; }
internal PlayerKVScratchState(bool present,int EventID,int ScratchMainID,int ChainPackID,int Token,int ConsumedToken,int RewardTaskConsumedToken,int EventScratchRewardTaskID,int EventScratchCardID,int TotalConsumedToken,int ChainPackProgress,int magIndex,bool IsOver,float LuckyValue,float InflationRate,List<int> StampRewardList,Dictionary<int,PlayerKVStampValue> StampRewardDict)
{
IsPresent=present;
this.EventID=EventID;
this.ScratchMainID=ScratchMainID;
this.ChainPackID=ChainPackID;
this.Token=Token;
this.ConsumedToken=ConsumedToken;
this.RewardTaskConsumedToken=RewardTaskConsumedToken;
this.EventScratchRewardTaskID=EventScratchRewardTaskID;
this.EventScratchCardID=EventScratchCardID;
this.TotalConsumedToken=TotalConsumedToken;
this.ChainPackProgress=ChainPackProgress;
this.magIndex=magIndex;
this.IsOver=IsOver;
this.LuckyValue=LuckyValue;
this.InflationRate=InflationRate;
this.StampRewardList=new List<int>(StampRewardList).AsReadOnly();
this.StampRewardDict=new ReadOnlyDictionary<int,PlayerKVStampValue>(new Dictionary<int,PlayerKVStampValue>(StampRewardDict));
}
internal static PlayerKVScratchState Missing() => new PlayerKVScratchState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new Dictionary<int,PlayerKVStampValue>());
}
internal sealed class PlayerKVOfferItem
{
internal int Id { get; }
internal int Item { get; }
internal int Number { get; }
internal int Status { get; }
internal PlayerKVOfferItem(int id,int item,int number,int status) { Id=id; Item=item; Number=number; Status=status; }
}
internal sealed class PlayerKVOfferState
{
internal bool IsPresent { get; }
internal bool FirstOpen { get; }
internal int EventId { get; }
internal DateTime StartTime { get; }
internal DateTime EndTime { get; }
internal int ItemId { get; }
internal int LotteryNum { get; }
internal IReadOnlyList<PlayerKVOfferItem> Items { get; }
internal IReadOnlyList<int> RewardList { get; }
internal bool Finished { get; }
internal int ChestReward { get; }
internal PlayerKVOfferState(bool present,bool firstOpen,int eventId,DateTime startTime,DateTime endTime,
int itemId,int lotteryNum,IEnumerable<PlayerKVOfferItem> items,IEnumerable<int> rewardList,bool finished,int chestReward)
{
IsPresent=present; FirstOpen=firstOpen; EventId=eventId; StartTime=startTime; EndTime=endTime;
ItemId=itemId; LotteryNum=lotteryNum; Items=new List<PlayerKVOfferItem>(items).AsReadOnly();
RewardList=new List<int>(rewardList).AsReadOnly(); Finished=finished; ChestReward=chestReward;
}
internal static PlayerKVOfferState Missing() => new PlayerKVOfferState(false,false,0,default,default,0,0,
Array.Empty<PlayerKVOfferItem>(),Array.Empty<int>(),false,0);
}
internal sealed class PlayerKVEventConfig
{
internal int RedirectId { get; }
internal string EndTime { get; }
internal PlayerKVEventConfig(int redirectId,string endTime) { RedirectId=redirectId; EndTime=endTime; }
}
internal sealed class PlayerKVEventPackState
{
internal int EventId { get; }
internal int PurchaseCount { get; }
internal int VipLevel { get; }
internal int RedirectId { get; }
internal PlayerKVEventPackState(int eventId,int purchaseCount,int vipLevel,int redirectId)
{ EventId=eventId; PurchaseCount=purchaseCount; VipLevel=vipLevel; RedirectId=redirectId; }
}
internal sealed class PlayerKVCampState
{
internal bool IsFix { get; }
internal string Skyscraper { get; }
internal IReadOnlyDictionary<string,int> Values { get; }
internal PlayerKVCampState(bool isFix,string skyscraper,IDictionary<string,int> values)
{ IsFix=isFix; Skyscraper=skyscraper; Values=new ReadOnlyDictionary<string,int>(new Dictionary<string,int>(values,StringComparer.Ordinal)); }
}
internal sealed class PlayerKVClubState
{
internal DateTime? SignTime { get; }
internal DateTime DonateTime { get; }
internal int DonateCount { get; }
internal DateTime RequestEnd { get; }
internal bool OpenRed { get; }
internal PlayerKVClubState(DateTime? signTime, DateTime donateTime, int donateCount, DateTime requestEnd, bool openRed)
{ SignTime=signTime; DonateTime=donateTime; DonateCount=donateCount; RequestEnd=requestEnd; OpenRed=openRed; }
}
internal sealed class PlayerKVProgressPackState
{
internal bool IsPresent { get; }
internal int EventId { get; }
internal int RedirectId { get; }
internal int TokenProgress { get; }
internal int ChainProgress { get; }
internal PlayerKVProgressPackState(bool present, int eventId, int redirectId, int tokenProgress, int chainProgress)
{ IsPresent=present; EventId=eventId; RedirectId=redirectId; TokenProgress=tokenProgress; ChainProgress=chainProgress; }
}
internal sealed class PlayerKVIntMapState
{
internal IReadOnlyDictionary<int, int> Values { get; }
internal PlayerKVIntMapState(IDictionary<int, int> values)
{ Values = new ReadOnlyDictionary<int, int>(new Dictionary<int, int>(values)); }
}
internal sealed class PlayerKVGuideValue
{
internal int Index { get; }
internal bool Finished { get; }
internal string Group { get; }
internal PlayerKVGuideValue(int index, bool finished, string group)
{ Index = index; Finished = finished; Group = group; }
internal bool Same(PlayerKVGuideValue other) => Index == other.Index && Finished == other.Finished && Group == other.Group;
}
internal sealed class PlayerKVGuideState
{
internal IReadOnlyDictionary<string, PlayerKVGuideValue> Values { get; }
internal PlayerKVGuideState(IDictionary<string, PlayerKVGuideValue> values)
{ Values = new ReadOnlyDictionary<string, PlayerKVGuideValue>(new Dictionary<string, PlayerKVGuideValue>(values, StringComparer.Ordinal)); }
}
internal sealed class PlayerKVMonthlySignState
{
internal bool IsPresent { get; }
internal int Index { get; }
internal int DailyId { get; }
internal int DailyIndex { get; }
internal int LastId { get; }
internal string Time { get; }
internal PlayerKVMonthlySignState(bool present, int index, int dailyId, int dailyIndex, int lastId, string time)
{ IsPresent = present; Index = index; DailyId = dailyId; DailyIndex = dailyIndex; LastId = lastId; Time = time; }
}
internal sealed class PlayerKVDailyValue
{
internal int Id { get; } internal int Index { get; } internal int LastId { get; } internal string Time { get; }
internal PlayerKVDailyValue(int id,int index,int lastId,string time) { Id=id; Index=index; LastId=lastId; Time=time; }
}
internal sealed class PlayerKVAdvertValue
{
internal int Id { get; } internal int Index { get; } internal int Day { get; }
internal string Time { get; } internal bool Open { get; }
internal PlayerKVAdvertValue(int id,int index,int day,string time,bool open) { Id=id; Index=index; Day=day; Time=time; Open=open; }
}
internal sealed class PlayerKVShopItemValue
{
internal int Id { get; } internal float Discount { get; } internal int Bought { get; }
internal PlayerKVShopItemValue(int id,float discount,int bought) { Id=id; Discount=discount; Bought=bought; }
}
internal sealed class PlayerKVCouponState
{
internal DateTime ActivateTime { get; } internal int EventId { get; } internal int RedirectId { get; } internal bool Bought { get; }
internal PlayerKVCouponState(DateTime time,int eventId,int redirectId,bool bought) { ActivateTime=time; EventId=eventId; RedirectId=redirectId; Bought=bought; }
}
internal sealed class PlayerKVShopState
{
internal IReadOnlyDictionary<string,int> Daily { get; }
internal IReadOnlyDictionary<int,int> All { get; } internal IReadOnlyDictionary<int,int> Gift { get; }
internal IReadOnlyDictionary<int,int> Advert { get; }
internal IReadOnlyDictionary<int,PlayerKVShopItemValue> Items { get; }
internal IReadOnlyList<float> Buy { get; }
internal int Voucher { get; } internal int RefreshCount { get; } internal DateTime RefreshTime { get; }
internal PlayerKVDailyValue Chain { get; } internal PlayerKVDailyValue Turntable { get; }
internal PlayerKVAdvertValue Vip { get; } internal PlayerKVCouponState Coupon { get; }
internal PlayerKVShopState(IDictionary<string,int> daily,IDictionary<int,int> all,IDictionary<int,int> gift,
IDictionary<int,int> advert,IDictionary<int,PlayerKVShopItemValue> items,IEnumerable<float> buy,int voucher,int refreshCount,
DateTime refreshTime,PlayerKVDailyValue chain,PlayerKVDailyValue turntable,PlayerKVAdvertValue vip,PlayerKVCouponState coupon)
{
Daily=new ReadOnlyDictionary<string,int>(new Dictionary<string,int>(daily,StringComparer.Ordinal));
All=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(all)); Gift=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(gift));
Advert=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(advert));
Items=new ReadOnlyDictionary<int,PlayerKVShopItemValue>(new Dictionary<int,PlayerKVShopItemValue>(items));
Buy=new List<float>(buy).AsReadOnly(); Voucher=voucher; RefreshCount=refreshCount; RefreshTime=refreshTime;
Chain=chain; Turntable=turntable; Vip=vip; Coupon=coupon;
}
}
internal sealed class PlayerKVLimitedValue
{
internal int Id { get; } internal double Duration { get; } internal string StartTime { get; }
internal PlayerKVLimitedValue(int id,double duration,string time) { Id=id; Duration=duration; StartTime=time; }
}
internal sealed class PlayerKVEventBaseState
{
internal IReadOnlyDictionary<int,PlayerKVLimitedValue> Limited { get; }
internal IReadOnlyDictionary<int,PlayerKVFixedTimeValue> Fixed { get; }
internal IReadOnlyDictionary<int,int> Target { get; } internal IReadOnlyDictionary<int,int> Rank { get; }
internal IReadOnlyDictionary<int,int> Wheel { get; }
internal int Coupon { get; } internal int Operate { get; } internal int AllOperate { get; }
internal int Free { get; } internal int PvpToken { get; }
internal PlayerKVEventBaseState(IDictionary<int,PlayerKVLimitedValue> limited,IDictionary<int,PlayerKVFixedTimeValue> fixedValues,
IDictionary<int,int> target,IDictionary<int,int> rank,IDictionary<int,int> wheel,int coupon,int operate,int allOperate,int free,int pvpToken)
{
Limited=new ReadOnlyDictionary<int,PlayerKVLimitedValue>(new Dictionary<int,PlayerKVLimitedValue>(limited));
Fixed=new ReadOnlyDictionary<int,PlayerKVFixedTimeValue>(new Dictionary<int,PlayerKVFixedTimeValue>(fixedValues));
Target=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(target)); Rank=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(rank));
Wheel=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(wheel)); Coupon=coupon; Operate=operate; AllOperate=allOperate; Free=free; PvpToken=pvpToken;
}
}
internal sealed class PlayerKVFishConfig
{
internal int Id { get; } internal int Area { get; } internal int Quality { get; } internal int Card { get; }
internal float Weight { get; } internal float Extra { get; } internal float Random { get; }
internal PlayerKVFishConfig(int id,int area,int quality,int card,float weight,float extra,float random)
{ Id=id; Area=area; Quality=quality; Card=card; Weight=weight; Extra=extra; Random=random; }
}
internal sealed class PlayerKVFishCardConfig
{
internal int Id { get; } internal int MaxLevel { get; }
internal IReadOnlyList<int> Required { get; } internal IReadOnlyList<float> Weights { get; }
internal PlayerKVFishCardConfig(int id,int max,IEnumerable<int> required,IEnumerable<float> weights)
{ Id=id; MaxLevel=max; Required=new List<int>(required).AsReadOnly(); Weights=new List<float>(weights).AsReadOnly(); }
}
internal sealed class PlayerKVFishValue
{
internal int Level { get; } internal float Weight { get; } internal int Cards { get; }
internal int Number { get; } internal bool New { get; }
internal IReadOnlyList<string> Compatibility { get; }
internal PlayerKVFishValue(int level,float weight,int cards,int number,bool isNew,IEnumerable<string> compatibility=null)
{ Level=level; Weight=weight; Cards=cards; Number=number; New=isNew; Compatibility=new List<string>(compatibility ?? Array.Empty<string>()).AsReadOnly(); }
internal bool Same(PlayerKVFishValue other) => Level==other.Level && Weight==other.Weight && Cards==other.Cards && Number==other.Number && New==other.New;
}
internal sealed class PlayerKVRodAccessories
{
internal int Glove { get; } internal IReadOnlyDictionary<int,int> RodSkin { get; }
internal IReadOnlyList<int> Gloves { get; } internal IReadOnlyList<int> Skins { get; }
internal IReadOnlyList<int> NewGloves { get; } internal IReadOnlyList<int> NewSkins { get; }
internal PlayerKVRodAccessories(int glove,IDictionary<int,int> rodSkin,IEnumerable<int> gloves,IEnumerable<int> skins,IEnumerable<int> newGloves,IEnumerable<int> newSkins)
{ Glove=glove; RodSkin=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(rodSkin)); Gloves=new List<int>(gloves).AsReadOnly();
Skins=new List<int>(skins).AsReadOnly(); NewGloves=new List<int>(newGloves).AsReadOnly(); NewSkins=new List<int>(newSkins).AsReadOnly(); }
}
internal sealed class PlayerKVFishState
{
internal IReadOnlyDictionary<int,int> Rods { get; } internal IReadOnlyDictionary<int,int> RodLevels { get; }
internal IReadOnlyDictionary<int,int> Swim { get; } internal IReadOnlyDictionary<int,int> SwimCount { get; }
internal IReadOnlyDictionary<int,int> Progress { get; } internal IReadOnlyDictionary<int,int> Honor { get; }
internal IReadOnlyDictionary<string,float> Luck { get; } internal PlayerKVRodAccessories Accessories { get; }
internal IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> RawMaps { get; }
internal IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> Maps { get; }
internal IReadOnlyDictionary<int,PlayerKVFishValue> Fish { get; } internal IReadOnlyDictionary<int,int> Cards { get; }
static IReadOnlyDictionary<int,int> Copy(IDictionary<int,int> values) => new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(values));
static IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> CopyMaps(IDictionary<int,Dictionary<int,PlayerKVFishValue>> maps) =>
new ReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>>(maps.ToDictionary(p=>p.Key,p=>(IReadOnlyDictionary<int,PlayerKVFishValue>)new ReadOnlyDictionary<int,PlayerKVFishValue>(new Dictionary<int,PlayerKVFishValue>(p.Value))));
internal PlayerKVFishState(IDictionary<int,int> rods,IDictionary<int,int> rodLevels,IDictionary<int,int> swim,IDictionary<int,int> swimCount,
IDictionary<int,int> progress,IDictionary<int,int> honor,IDictionary<string,float> luck,PlayerKVRodAccessories accessories,
IDictionary<int,Dictionary<int,PlayerKVFishValue>> rawMaps,IDictionary<int,Dictionary<int,PlayerKVFishValue>> maps,
IDictionary<int,PlayerKVFishValue> fish,IDictionary<int,int> cards)
{ Rods=Copy(rods); RodLevels=Copy(rodLevels); Swim=Copy(swim); SwimCount=Copy(swimCount); Progress=Copy(progress); Honor=Copy(honor);
Luck=new ReadOnlyDictionary<string,float>(new Dictionary<string,float>(luck,StringComparer.Ordinal)); Accessories=accessories;
RawMaps=CopyMaps(rawMaps); Maps=CopyMaps(maps); Fish=new ReadOnlyDictionary<int,PlayerKVFishValue>(new Dictionary<int,PlayerKVFishValue>(fish)); Cards=Copy(cards); }
}
internal sealed class PlayerKVFootPrintState
{
internal ulong Bits { get; }
internal PlayerKVFootPrintState(ulong bits) { Bits = bits; }
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 35b5e1a67b2043b5aa3c34a67d2fe8f0

View File

@@ -1,12 +1,34 @@
using System; using System;
using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace game.PlayerDataStorage namespace game.PlayerDataStorage
{ {
internal enum PlayerKVGameLoadError internal enum PlayerKVGameLoadError
{ None, BackendNotEnabled, BackendUnsupported, Input, Prepare, Schema, Apply, Stale, SideEffect, GateBusy, PreviousClosePending, PreviousCloseFailed, ClosePending, CloseFailed } {
internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation, GameLoaded } None,
BackendNotEnabled,
BackendUnsupported,
Input,
Prepare,
Schema,
Apply,
Stale,
SideEffect,
GateBusy,
PreviousClosePending,
PreviousCloseFailed,
ClosePending,
CloseFailed
}
internal enum PlayerKVGameLoadState
{
Rejected,
PreparedForGame
}
// Preserve stage evidence without retaining a successful Prepare payload on a later load failure. // Preserve stage evidence without retaining a successful Prepare payload on a later load failure.
internal sealed class PlayerKVPrepareEvidence internal sealed class PlayerKVPrepareEvidence
{ {
@@ -20,232 +42,415 @@ namespace game.PlayerDataStorage
internal int? HttpStatusCode { get; } internal int? HttpStatusCode { get; }
internal string ExceptionType { get; } internal string ExceptionType { get; }
internal string CleanupExceptionType { get; } internal string CleanupExceptionType { get; }
internal PlayerKVPrepareEvidence(PlayerKVLoginPrepareResult value) internal PlayerKVPrepareEvidence(PlayerKVLoginPrepareResult value)
{ Error=value.Error; OriginalError=value.OriginalError; AccountError=value.AccountError; OpenError=value.OpenError; {
OwnerError=value.OwnerError; KVError=value.KVError; SyncStatus=value.SyncStatus; HttpStatusCode=value.HttpStatusCode; Error = value.Error;
ExceptionType=value.ExceptionType; CleanupExceptionType=value.CleanupExceptionType; } OriginalError = value.OriginalError;
AccountError = value.AccountError;
OpenError = value.OpenError;
OwnerError = value.OwnerError;
KVError = value.KVError;
SyncStatus = value.SyncStatus;
HttpStatusCode = value.HttpStatusCode;
ExceptionType = value.ExceptionType;
CleanupExceptionType = value.CleanupExceptionType;
}
} }
internal sealed class PlayerKVGameLoadResult internal sealed class PlayerKVGameLoadResult
{ {
internal PlayerKVGameLoadState State { get; } internal PlayerKVGameLoadState State
internal PlayerKVGameLoadError Error { get; } {
internal PlayerKVGameLoadError OriginalError { get; } get;
internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed; }
internal PlayerKVSchemaVerdict? SchemaVerdict { get; } internal PlayerKVGameLoadError Error
internal PlayerKVPrepareEvidence Prepare { get; } {
internal long Epoch { get; } get;
internal PlayerKVAccountDescriptor Account { get; } }
internal int ModuleCount { get; } internal PlayerKVGameLoadError OriginalError
internal int IntentCount { get; } {
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null, get;
PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0,bool forGame=false,PlayerKVAccountDescriptor account=null) }
{ Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents; internal bool CleanupFailed => Error == PlayerKVGameLoadError.CloseFailed;
Account=error==PlayerKVGameLoadError.None && forGame ? account : null; internal PlayerKVPrepareEvidence Prepare
State=error!=PlayerKVGameLoadError.None ? PlayerKVGameLoadState.Rejected : forGame ? PlayerKVGameLoadState.GameLoaded : PlayerKVGameLoadState.LoadedForValidation; } {
get;
}
internal long Epoch
{
get;
}
internal PlayerKVAccountDescriptor Account
{
get;
}
internal IReadOnlyDictionary<string, string> Data
{
get;
}
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error, long epoch, PlayerKVGameLoadError? original = null, PlayerKVLoginPrepareResult prepare = null, PlayerKVAccountDescriptor account = null, IReadOnlyDictionary<string, string> data = null)
{
Error = error;
OriginalError = original ?? error;
Prepare = prepare == null ? null : new PlayerKVPrepareEvidence(prepare);
Epoch = epoch;
Account = error == PlayerKVGameLoadError.None ? account : null;
Data = error == PlayerKVGameLoadError.None ? data : null;
State = error == PlayerKVGameLoadError.None ? PlayerKVGameLoadState.PreparedForGame : PlayerKVGameLoadState.Rejected;
}
} }
internal sealed class PlayerKVGameLoadCoordinator internal sealed class PlayerKVGameLoadCoordinator
{ {
readonly SemaphoreSlim loginGate=new SemaphoreSlim(1,1); private readonly SemaphoreSlim loginGate = new SemaphoreSlim(1, 1);
readonly object closeGate=new object(); private readonly object closeGate = new object ();
readonly PlayerKVGameLoadTransaction transaction; private readonly Func<bool> unstarted;
readonly Func<bool> unstarted; private readonly Action<PlayerKVAccountLease> bind;
readonly Action notify; private readonly Func<Task> unbind;
readonly Func<int> intentCount; private readonly PlayerKVShutdownRegistry registry;
readonly Action<PlayerKVAccountLease> bind; private readonly int mainThread;
readonly Func<Task> unbind; private readonly SynchronizationContext mainContext;
readonly PlayerKVShutdownRegistry registry; private PlayerKVLoginContext context;
readonly int mainThread; private PlayerKVGameLoadGuard guard;
readonly SynchronizationContext mainContext; private PlayerKVGameLoadCapability capability;
PlayerKVLoginContext context; private Task closing;
PlayerKVGameLoadGuard guard; private CancellationTokenRegistration lifetimeRegistration;
PlayerKVGameLoadCapability capability; private long epoch;
Task closing; private PlayerKVGameSessionHandle gameHandle;
CancellationTokenRegistration lifetimeRegistration;
Action clearAfterApply;
long epoch;
bool loaded;
PlayerKVGameSessionHandle gameHandle;
internal bool LoadedForValidation => loaded && context!=null && context.IsCurrent && guard!=null && guard.IsCurrent && guard.Violations==0;
internal int QuitObserveMs => capability?.QuitObserveMs ?? 5000; internal int QuitObserveMs => capability?.QuitObserveMs ?? 5000;
internal PlayerKVGameLoadCoordinator(PlayerKVGameLoadTransaction transaction,Func<bool> unstarted,Action notify,Func<int> intentCount,
Action<PlayerKVAccountLease> bind,Func<Task> unbind,PlayerKVShutdownRegistry registry=null) internal PlayerKVGameLoadCoordinator(Func<bool> unstarted, Action<PlayerKVAccountLease> bind, Func<Task> unbind, PlayerKVShutdownRegistry registry = null)
{ {
this.transaction=transaction ?? throw new ArgumentNullException(nameof(transaction)); this.unstarted = unstarted ?? throw new ArgumentNullException(nameof(unstarted));
this.unstarted=unstarted ?? throw new ArgumentNullException(nameof(unstarted)); this.notify=notify ?? throw new ArgumentNullException(nameof(notify)); this.bind = bind ?? throw new ArgumentNullException(nameof(bind));
this.intentCount=intentCount ?? throw new ArgumentNullException(nameof(intentCount)); this.bind=bind ?? throw new ArgumentNullException(nameof(bind)); this.unbind = unbind ?? throw new ArgumentNullException(nameof(unbind));
this.unbind=unbind ?? throw new ArgumentNullException(nameof(unbind)); this.registry=registry ?? PlayerKVShutdownRegistry.Process; this.registry = registry ?? PlayerKVShutdownRegistry.Process;
mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; mainThread = Thread.CurrentThread.ManagedThreadId;
mainContext = SynchronizationContext.Current;
} }
internal Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
=> LoadCoreAsync(id,next,token,identityCurrent,false); internal Task<PlayerKVGameLoadResult> LoadForGameAsync(string id, PlayerKVGameLoadCapability next,
internal Task<PlayerKVGameLoadResult> LoadForGameAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null) CancellationToken token = default, Func<bool> identityCurrent = null)
=> LoadCoreAsync(id,next,token,identityCurrent,true);
internal bool TryGetCurrentGameHandle(long expectedEpoch,out PlayerKVGameSessionHandle handle)
{ {
handle=null; return LoadCoreAsync(id, next, token, identityCurrent);
PlayerKVLoginContext owner; PlayerKVGameSessionHandle published; }
lock(closeGate) internal bool TryGetCurrentGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle handle)
{
handle = null;
PlayerKVLoginContext owner;
PlayerKVGameSessionHandle published;
lock (closeGate)
{ {
if(closing!=null || epoch!=expectedEpoch || gameHandle==null) return false; if (closing != null || epoch != expectedEpoch || gameHandle == null)
owner=context; published=gameHandle; {
return false;
}
owner = context;
published = gameHandle;
} }
// Never call the external identity predicate while holding a publication monitor. // Never call the external identity predicate while holding a publication monitor.
if(!owner.TryAcquireGameHandle(expectedEpoch,out var original) || !ReferenceEquals(original,published)) return false; if (!owner.TryAcquireGameHandle(expectedEpoch, out var original) || !ReferenceEquals(original, published))
lock(closeGate)
{ {
if(closing!=null || epoch!=expectedEpoch || !ReferenceEquals(context,owner) || !ReferenceEquals(gameHandle,published)) return false; return false;
handle=published; }
lock (closeGate)
{
if (closing != null || epoch != expectedEpoch || !ReferenceEquals(context, owner) || !ReferenceEquals(gameHandle, published))
{
return false;
}
handle = published;
return true; return true;
} }
} }
async Task<PlayerKVGameLoadResult> LoadCoreAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token,Func<bool> identityCurrent,bool forGame)
async Task<PlayerKVGameLoadResult> LoadCoreAsync(string id, PlayerKVGameLoadCapability next, CancellationToken token, Func<bool> identityCurrent)
{ {
if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id)) if (next == null || Thread.CurrentThread.ManagedThreadId != mainThread || string.IsNullOrEmpty(id))
{
return Result(PlayerKVGameLoadError.Input); return Result(PlayerKVGameLoadError.Input);
if(!await EnterGate(next,token)) return Result(token.IsCancellationRequested ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.GateBusy); }
PlayerKVLoginPrepareResult prepared=null; PlayerKVSchemaVerdict? verdict=null;
long attempt=0; if (!await EnterGate(next, token))
{
return Result(token.IsCancellationRequested ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.GateBusy);
}
PlayerKVLoginPrepareResult prepared = null;
long attempt = 0;
try try
{ {
// An earlier successful validation must also be explicitly revoked before replacing its account. // An earlier successful validation must also be explicitly revoked before replacing its account.
if(context!=null) _=Revoke(); if (context != null)
var previous=await registry.ObserveAsync(next.LoginObserveMs,token,next.Delay); {
if(previous!=PlayerKVShutdownStatus.Ready) return Result(previous==PlayerKVShutdownStatus.Failed ? PlayerKVGameLoadError.PreviousCloseFailed : _ = Revoke();
previous==PlayerKVShutdownStatus.Cancelled ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.PreviousClosePending); }
if(token.IsCancellationRequested || !unstarted() || transaction.Poisoned) return Result(PlayerKVGameLoadError.Input);
if(!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard)) return Result(PlayerKVGameLoadError.GateBusy); var previous = await registry.ObserveAsync(next.LoginObserveMs, token, next.Delay);
if (previous != PlayerKVShutdownStatus.Ready)
{
return Result(previous == PlayerKVShutdownStatus.Failed ? PlayerKVGameLoadError.PreviousCloseFailed : previous == PlayerKVShutdownStatus.Cancelled ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.PreviousClosePending);
}
if (token.IsCancellationRequested || !unstarted())
{
return Result(PlayerKVGameLoadError.Input);
}
if (!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard))
{
return Result(PlayerKVGameLoadError.GateBusy);
}
lifetimeRegistration.Dispose(); lifetimeRegistration.Dispose();
lock(closeGate) lock (closeGate)
{ {
guard=enteredGuard; capability=next; closing=null; loaded=false; gameHandle=null; guard = enteredGuard;
attempt=Interlocked.Increment(ref epoch); capability = next;
context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent())); closing = null;
gameHandle = null;
attempt = Interlocked.Increment(ref epoch);
context = next.CreateContext(id, attempt, e => Interlocked.Read(ref epoch) == e && !token.IsCancellationRequested && (identityCurrent == null || identityCurrent()));
} }
var owner=context;
lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); }); var owner = context;
var work=context.PrepareAsync(token); lifetimeRegistration = token.Register(() =>
var cancelled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using(token.Register(()=>cancelled.TrySetResult(true)))
{ {
if(await Task.WhenAny(work,cancelled.Task)!=work) _ = Revoke(attempt);
return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); });
prepared=await work; var work = context.PrepareAsync(token);
} var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if(!prepared.Prepared) return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare,attempt,next,prepared,verdict); using (token.Register(() => cancelled.TrySetResult(true)))
if(!context.IsCurrent || epoch!=attempt) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
if(next.RequireExistingArchive && (context.AccountDescriptor?.NewlyCreated!=false || prepared.Data==null || prepared.Data.Count==0))
return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
var decoded=PlayerKVSaveSchema.Decode(prepared,context.AccountDescriptor,id,attempt,context.IsCurrent,next.Snapshot,next.Snapshot.Version,next.Identity);
verdict=decoded.Verdict;
if(decoded.Data==null) return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
if(!context.TryAcquireAccountLease(next.LeaseCapability,out var lease)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
bind(lease);
PlayerKVApplyError applied;
try { applied=transaction.Apply(decoded.Data,()=>context.IsCurrent && epoch==attempt && unstarted(),()=>guard.IsCurrent && guard.Violations==0,notify); }
finally { var pending=clearAfterApply; clearAfterApply=null; pending?.Invoke(); }
if(applied!=PlayerKVApplyError.None) return await Reject(applied==PlayerKVApplyError.Stale ? PlayerKVGameLoadError.Stale :
applied==PlayerKVApplyError.SideEffect ? PlayerKVGameLoadError.SideEffect : PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict);
if(token.IsCancellationRequested || !owner.IsCurrent || enteredGuard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
var intents=intentCount();
PlayerKVGameSessionHandle original=null;
if(forGame && !owner.TryAcquireGameHandle(attempt,out original)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
bool published=false;
lock(closeGate)
{ {
if(closing==null && epoch==attempt && !token.IsCancellationRequested && ReferenceEquals(context,owner) && enteredGuard.IsCurrent && enteredGuard.Violations==0) if (await Task.WhenAny(work, cancelled.Task) != work)
{ {
if(forGame) return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
prepared = await work;
}
if (!prepared.Prepared)
{
return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare, attempt, next, prepared);
}
if (!context.IsCurrent || epoch != attempt)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
if (next.RequireExistingArchive && (context.AccountDescriptor?.NewlyCreated != false || prepared.Data == null || prepared.Data.Count == 0))
{
return await Reject(PlayerKVGameLoadError.Schema, attempt, next, prepared);
}
if (!context.TryAcquireAccountLease(next.LeaseCapability, out var lease))
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
bind(lease);
if (token.IsCancellationRequested || !owner.IsCurrent || enteredGuard.Violations != 0)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
var account = owner.AccountDescriptor;
if (account == null)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
PlayerKVGameSessionHandle original = null;
if (!owner.TryAcquireGameHandle(attempt, out original))
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
bool published = false;
lock (closeGate)
{
if (closing == null && epoch == attempt && !token.IsCancellationRequested && ReferenceEquals(context, owner) && enteredGuard.IsCurrent && enteredGuard.Violations == 0)
{
enteredGuard.Dispose();
// Dispose serializes with Reject; its final count cannot change afterwards.
if (enteredGuard.Violations == 0)
{ {
enteredGuard.Dispose(); gameHandle = original;
// Dispose serializes with Reject; its final count cannot change afterwards. published = true;
if(enteredGuard.Violations==0) { gameHandle=original; published=true; }
} }
else { loaded=true; published=true; }
} }
} }
if(!published) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intents,forGame:forGame,account:context.AccountDescriptor); if (!published)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None, attempt, account: account, data: prepared.Data);
} }
catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); } catch
finally { loginGate.Release(); }
}
async Task<bool> EnterGate(PlayerKVGameLoadCapability next,CancellationToken token)
{
using(var cancellation=CancellationTokenSource.CreateLinkedTokenSource(token))
{ {
var wait=loginGate.WaitAsync(cancellation.Token); var deadline=next.Delay(next.LoginObserveMs,cancellation.Token); return await Reject(PlayerKVGameLoadError.Apply, attempt, next, prepared);
bool acquired=false,accepted=false; }
finally
{
loginGate.Release();
}
}
async Task<bool> EnterGate(PlayerKVGameLoadCapability next, CancellationToken token)
{
using (var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token))
{
var wait = loginGate.WaitAsync(cancellation.Token);
var deadline = next.Delay(next.LoginObserveMs, cancellation.Token);
bool acquired = false, accepted = false;
try try
{ {
if(await Task.WhenAny(wait,deadline)==wait) { await wait; acquired=true; accepted=!token.IsCancellationRequested && !deadline.IsCompleted; return accepted; } if (await Task.WhenAny(wait, deadline) == wait)
{
await wait;
acquired = true;
accepted = !token.IsCancellationRequested && !deadline.IsCompleted;
return accepted;
}
cancellation.Cancel(); cancellation.Cancel();
try { await wait; acquired=true; } catch(OperationCanceledException) { } try
{
await wait;
acquired = true;
}
catch (OperationCanceledException)
{
}
return false;
}
catch (OperationCanceledException)
{
return false; return false;
} }
catch(OperationCanceledException) { return false; }
finally finally
{ {
cancellation.Cancel(); cancellation.Cancel();
// A lock won at the timeout/cancel boundary belongs to this attempt and must be returned. // A lock won at the timeout/cancel boundary belongs to this attempt and must be returned.
if(acquired && !accepted) loginGate.Release(); if (acquired && !accepted)
{
loginGate.Release();
}
} }
} }
} }
internal Task Revoke(long? expectedEpoch=null)
internal Task Revoke(long? expectedEpoch = null)
{ {
lock(closeGate) lock (closeGate)
{ {
if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask; if (expectedEpoch.HasValue && expectedEpoch.Value != Interlocked.Read(ref epoch))
if(closing!=null) return closing; {
loaded=false; gameHandle=null; Interlocked.Increment(ref epoch); return Task.CompletedTask;
if(context==null && guard==null) return Task.CompletedTask; }
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
closing=completion.Task; if (closing != null)
var owner=context; var leaseGuard=guard; var source=capability; {
// Context cancellation is synchronous and occurs even if the main-thread reset is queued. return closing;
var drain=owner?.CloseAsync() ?? Task.CompletedTask; }
var cleared=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
gameHandle = null;
Interlocked.Increment(ref epoch);
if (context == null && guard == null)
{
return Task.CompletedTask;
}
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
closing = completion.Task;
var owner = context;
var leaseGuard = guard;
var source = capability;
// Context cancellation is synchronous and occurs even if the main-thread unbind is queued.
var drain = owner?.CloseAsync() ?? Task.CompletedTask;
var cleared = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
void Clear() void Clear()
{ {
try try
{ {
var detached=unbind(); var detached = unbind();
if(!transaction.Clear()) throw new InvalidOperationException("Controlled container is poisoned."); CompleteClear(detached, cleared);
CompleteClear(detached,cleared); }
catch (Exception ex)
{
cleared.TrySetException(ex);
} }
catch(Exception ex) { cleared.TrySetException(ex); }
} }
if(Thread.CurrentThread.ManagedThreadId==mainThread && transaction.IsApplying) clearAfterApply=Clear;
else if(Thread.CurrentThread.ManagedThreadId==mainThread) Clear(); if (Thread.CurrentThread.ManagedThreadId == mainThread)
else if(mainContext!=null) mainContext.Post(_=>Clear(),null); {
else cleared.TrySetException(new InvalidOperationException("Main-thread cleanup unavailable.")); Clear();
_=FinishClose(drain,cleared.Task,leaseGuard,source,completion); }
registry.Register(drain,owner); else if (mainContext != null)
registry.Register(cleared.Task,owner); {
registry.Register(closing,owner); mainContext.Post(_ => Clear(), null);
}
else
{
cleared.TrySetException(new InvalidOperationException("Main-thread cleanup unavailable."));
}
_ = FinishClose(drain, cleared.Task, leaseGuard, source, completion);
registry.Register(drain, owner);
registry.Register(cleared.Task, owner);
registry.Register(closing, owner);
return closing; return closing;
} }
} }
static async void CompleteClear(Task detached,TaskCompletionSource<bool> cleared)
{ try { await detached; cleared.TrySetResult(true); } catch(Exception ex) { cleared.TrySetException(ex); } } static async void CompleteClear(Task detached, TaskCompletionSource<bool> cleared)
static async Task FinishClose(Task drain,Task clear,PlayerKVGameLoadGuard leaseGuard,PlayerKVGameLoadCapability source,TaskCompletionSource<bool> completion)
{ {
try try
{ {
await Task.WhenAll(drain,clear).ConfigureAwait(false); source?.CloseUnused(); leaseGuard?.Dispose(); completion.TrySetResult(true); await detached;
cleared.TrySetResult(true);
}
catch (Exception ex)
{
cleared.TrySetException(ex);
} }
catch(Exception ex) { completion.TrySetException(ex); }
} }
async Task<PlayerKVGameLoadResult> Reject(PlayerKVGameLoadError original,long attempt,PlayerKVGameLoadCapability source,
PlayerKVLoginPrepareResult prepare,PlayerKVSchemaVerdict? verdict) static async Task FinishClose(Task drain, Task clear, PlayerKVGameLoadGuard leaseGuard, PlayerKVGameLoadCapability source, TaskCompletionSource<bool> completion)
{ {
var close=Revoke(); try
var status=await registry.ObserveAsync(source.LoginObserveMs,CancellationToken.None,source.Delay); {
var error=status==PlayerKVShutdownStatus.Failed || close.IsFaulted ? PlayerKVGameLoadError.CloseFailed : await Task.WhenAll(drain, clear).ConfigureAwait(false);
!close.IsCompleted ? PlayerKVGameLoadError.ClosePending : original; source?.CloseUnused();
return new PlayerKVGameLoadResult(error,attempt,original:prepare?.CleanupFailed==true ? PlayerKVGameLoadError.Prepare : original,verdict:verdict,prepare:prepare); leaseGuard?.Dispose();
completion.TrySetResult(true);
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
} }
PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error,Interlocked.Read(ref epoch));
internal Task<PlayerKVShutdownStatus> ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs,delay:capability?.Delay); async Task<PlayerKVGameLoadResult> Reject(PlayerKVGameLoadError original, long attempt, PlayerKVGameLoadCapability source, PlayerKVLoginPrepareResult prepare)
{
var close = Revoke();
var status = await registry.ObserveAsync(source.LoginObserveMs, CancellationToken.None, source.Delay);
var error = status == PlayerKVShutdownStatus.Failed || close.IsFaulted ? PlayerKVGameLoadError.CloseFailed : !close.IsCompleted ? PlayerKVGameLoadError.ClosePending : original;
return new PlayerKVGameLoadResult(error, attempt, original: prepare?.CleanupFailed == true ? PlayerKVGameLoadError.Prepare : original, prepare: prepare);
}
PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error, Interlocked.Read(ref epoch));
internal Task<PlayerKVShutdownStatus> ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs, delay: capability?.Delay);
} }
} }

View File

@@ -1,100 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace game.PlayerDataStorage
{
internal interface IPlayerKVLoadModule
{
int Id { get; }
void Reset();
void Replace(PlayerKVDecodedSave decoded);
}
internal sealed class PlayerKVLoadModule : IPlayerKVLoadModule
{
readonly Action reset;
readonly Action<PlayerKVDecodedSave> replace;
public int Id { get; }
internal PlayerKVLoadModule(int id,Action reset,Action<PlayerKVDecodedSave> replace)
{ Id=id; this.reset=reset ?? throw new ArgumentNullException(nameof(reset)); this.replace=replace ?? throw new ArgumentNullException(nameof(replace)); }
public void Reset() => reset();
public void Replace(PlayerKVDecodedSave decoded) => replace(decoded);
}
internal enum PlayerKVApplyError { None, Input, Stale, Apply, SideEffect, Poisoned }
internal sealed class PlayerKVGameLoadTransaction
{
readonly IPlayerKVLoadModule[] modules;
readonly int mainThread;
readonly Action discard;
bool applying;
internal bool IsApplying => applying;
internal bool Poisoned { get; private set; }
internal int AppliedModules { get; private set; }
internal PlayerKVGameLoadTransaction(IEnumerable<IPlayerKVLoadModule> modules,int mainThread,Action discard)
{
this.modules=modules?.ToArray() ?? throw new ArgumentNullException(nameof(modules));
if(this.modules.Length!=31 || !this.modules.Select(m=>m?.Id ?? 0).SequenceEqual(Enumerable.Range(1,31)))
throw new ArgumentException("Exactly 31 ordered modules required.");
this.mainThread=mainThread; this.discard=discard ?? throw new ArgumentNullException(nameof(discard));
}
// Synchronous main-thread region: no callback may yield between Reset and publication.
internal PlayerKVApplyError Apply(PlayerKVDecodedSave decoded,Func<bool> current,Func<bool> clean,Action notify)
{
if(Poisoned) return PlayerKVApplyError.Poisoned;
if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread || decoded==null || current==null || clean==null || notify==null)
return PlayerKVApplyError.Input;
applying=true; AppliedModules=0;
PlayerKVApplyError error=PlayerKVApplyError.Apply;
try
{
if(!current()) error=PlayerKVApplyError.Stale;
else if(!clean()) error=PlayerKVApplyError.SideEffect;
else if(!ResetAll()) error=PlayerKVApplyError.Poisoned;
else
{
foreach(var module in modules)
{
if(!current()) { error=PlayerKVApplyError.Stale; break; }
if(!clean()) { error=PlayerKVApplyError.SideEffect; break; }
module.Replace(decoded); AppliedModules++;
}
if(AppliedModules==31)
{
// The last module can revoke the epoch or trip a guard; never publish in that gap.
if(!current()) error=PlayerKVApplyError.Stale;
else if(!clean()) error=PlayerKVApplyError.SideEffect;
else
{
notify();
error=!current() ? PlayerKVApplyError.Stale : !clean() ? PlayerKVApplyError.SideEffect : PlayerKVApplyError.None;
}
}
}
}
catch { error=PlayerKVApplyError.Apply; }
finally { applying=false; }
if(error!=PlayerKVApplyError.None)
{
if(!ResetAll()) error=PlayerKVApplyError.Poisoned;
try { discard(); } catch { Poisoned=true; error=PlayerKVApplyError.Poisoned; }
AppliedModules=0;
}
return error;
}
internal bool Clear()
{
if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread) throw new InvalidOperationException("Main thread required.");
bool cleared=ResetAll();
try { discard(); } catch { Poisoned=true; cleared=false; }
AppliedModules=0; return cleared;
}
bool ResetAll()
{
bool ok=true;
foreach(var module in modules) try { module.Reset(); } catch { ok=false; }
if(!ok) Poisoned=true;
return ok;
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c1ea0dd4977649169863e3c917802188

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
@@ -87,8 +88,13 @@ namespace game.PlayerDataStorage
public PlayerKVGameSessionBridge() : this(jar => new HttpClientHandler public PlayerKVGameSessionBridge() : this(jar => new HttpClientHandler
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { } { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
internal PlayerKVGameSessionBridge(Func<CookieContainer, HttpMessageHandler> handlerFactory) private readonly bool prepareOriginalComparison;
{ this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); }
internal PlayerKVGameSessionBridge(Func<CookieContainer, HttpMessageHandler> handlerFactory, bool prepareOriginalComparison = false)
{
this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
this.prepareOriginalComparison = prepareOriginalComparison;
}
public Task<PlayerKVGameOpenResult> OpenAsync(S101AccountSession session, string cacheRoot, public Task<PlayerKVGameOpenResult> OpenAsync(S101AccountSession session, string cacheRoot,
string playerId, Uri api, long epoch, Func<long, bool> isCurrent, CancellationToken lifetimeToken = default) string playerId, Uri api, long epoch, Func<long, bool> isCurrent, CancellationToken lifetimeToken = default)
@@ -145,7 +151,7 @@ namespace game.PlayerDataStorage
active = slot; active = slot;
} }
slot.Creation = PlayerKVSessionOwner.CreateAsync(session, cacheRoot, playerId, api, slot.Creation = PlayerKVSessionOwner.CreateAsync(session, cacheRoot, playerId, api,
epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory); epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory, prepareOriginalComparison: prepareOriginalComparison);
var opened = await slot.Creation.ConfigureAwait(false); var opened = await slot.Creation.ConfigureAwait(false);
if (!opened.Success) if (!opened.Success)
{ {
@@ -261,9 +267,49 @@ namespace game.PlayerDataStorage
? new PlayerKVLocalReadResult(PlayerKVLocalReadError.StaleOrClosing, OwnerInstanceId, PlayerId, Epoch) ? new PlayerKVLocalReadResult(PlayerKVLocalReadError.StaleOrClosing, OwnerInstanceId, PlayerId, Epoch)
: Owner.ReadLocalSnapshot(); : Owner.ReadLocalSnapshot();
public PlayerKVEditResult Update(string key, string value, bool remoteStore = true) => public PlayerKVEditResult Update(string key, string value, bool remoteStore = true)
slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore); {
return Update(key, value, remoteStore, null);
}
internal PlayerKVStateResult CompareDatas(long nowTicks)
{
return slot.Revoked ? new PlayerKVStateResult(PlayerKVChangeError.Blocked) : Owner.CompareDatas(nowTicks);
}
internal PlayerKVEditResult Update(string key, string value, bool remoteStore, long? updateTicks)
{
return slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore, updateTicks);
}
internal Task<PlayerKVFlushResult> SyncDataAsync(CancellationToken waitToken = default)
{
if (slot.Revoked)
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status));
}
return Owner.SyncDataAsync(waitToken);
}
public PlayerKVEditResult Delete(string key) => slot.Revoked ? BlockedEdit() : Owner.Delete(key); public PlayerKVEditResult Delete(string key) => slot.Revoked ? BlockedEdit() : Owner.Delete(key);
public Task<bool> SyncDataImmidiate(string key, string value)
{
if (slot.Revoked)
{
return Task.FromResult(false);
}
return Owner.SyncDataImmidiate(key, value);
}
internal Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{
if (slot.Revoked)
{
return Task.FromResult(false);
}
return Owner.SyncDataImmidiate(values);
}
public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default) => slot.Revoked public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default) => slot.Revoked
? Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status)) ? Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status))
: Owner.FlushAsync(waitToken); : Owner.FlushAsync(waitToken);
@@ -273,4 +319,4 @@ namespace game.PlayerDataStorage
static PlayerKVEditResult BlockedEdit() => static PlayerKVEditResult BlockedEdit() =>
new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked)); new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
} }
} }

View File

@@ -1,60 +0,0 @@
#if !SERVICE_HOST
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using asap.core;
namespace game.PlayerDataStorage
{
// Bind the existing game container. Missing registrations fail closed; no parallel fixture objects.
internal static class PlayerKVGameplayBindings
{
static T Require<T>() where T:class => GContext.container.Resolve<T>() ??
throw new InvalidOperationException("KV game module is not registered: "+typeof(T).FullName);
internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc,string version="kv-loop")
{
var tables=Require<cfg.Tables>();
return new PlayerKVSchemaSnapshot(version,tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
Enum.GetNames(typeof(cfg.ConditionType)).ToDictionary(n=>n,n=>(int)Enum.Parse(typeof(cfg.ConditionType),n)),CultureInfo.InvariantCulture,
nowUtc,tables.TbGlobalConfig.InitWheelTicket,
tables.TbEventPackManager.DataMap.ToDictionary(p=>p.Key,p=>p.Value.PackType),
tables.TbFishingEvent.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVEventConfig(p.Value.RedirectID,(p.Value.TimeDefinition as cfg.LimitedTime)?.EndTime)),tables.TbGlobalConfig.GlobalTimeOffset,
tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)),
tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList)));
}
internal static PlayerKVGameLoadTransaction Bind(IKVUserLoadReceiver user)
{
var player=Require<GameCore.PlayerData>(); var small=Require<GameCore.SmallGameData>(); var fish=Require<GameCore.PlayerFishData>();
var shop=Require<GameCore.PlayerShopData>(); var eventBase=Require<GameCore.FishingEventData>(); var buffs=Require<BuffDataCenter>(); var camp=Require<CampDataMM>();
var achievements=Require<AchievementDataManager>(); var box=Require<FishingBoxData>(); var guide=Require<GameCore.GuideDataCenter>(); var battle=Require<BattlePassData>();
var sign=Require<GameCore.SigninData>(); var trigger=Require<GameCore.TriggerPackData>(); var club=Require<ClubService>(); var challenge=Require<FishingChallengeManager>();
var chains=Require<DataCenter.OfferChainsChestManager>(); var disco=Require<DataCenter.OfferDiscoPartyManager>(); var partner=Require<DataCenter.EventPartnerGatherManager>(); var cube=Require<EventCubeMeltManager>();
var eventPack=Require<EventPackData>(); var piggy=Require<PiggyBankPackData>(); var bargain=Require<BargainPackData>(); var foot=Require<IFootPrintService>() as FootPrintService ?? throw new InvalidOperationException("M23 FootPrint binding mismatch."); var shooting=Require<EventShootingRangeData>();
var potion=Require<EventPotionDataManager>(); var pinball=Require<EventPinballDataManager>(); var scratch=Require<EventScratchTicketDataManager>();
var progress=eventBase.ThanksGivingPackData; var migration=eventBase.ChainPackWithProgressMigrationData;
var pinata=Require<EventPinataPackDataManager>(); var digging=Require<Script.RuntimeScript.DiggingGameManager>();
var modules=new List<IPlayerKVLoadModule>();
void Add<T>(int id,Action reset,Action<T> replace) where T:class => modules.Add(new PlayerKVLoadModule(id,reset,d=>replace(d.Module<T>(id))));
Add<PlayerKVPlayerState>(1,player.ResetForKVLoad,player.ReplaceFromKVDecoded); Add<PlayerKVCoreState>(2,small.ResetForKVLoad,small.ReplaceFromKVDecoded);
Add<PlayerKVFishState>(3,fish.ResetForKVLoad,fish.ReplaceFromKVDecoded); Add<PlayerKVShopState>(4,shop.ResetForKVLoad,shop.ReplaceFromKVDecoded);
Add<PlayerKVEventBaseState>(5,eventBase.ResetForKVLoad,eventBase.ReplaceFromKVDecoded); Add<PlayerKVBuffState>(6,buffs.ResetForKVLoad,buffs.ReplaceFromKVDecoded);
Add<PlayerKVCampState>(7,camp.ResetForKVLoad,camp.ReplaceFromKVDecoded); Add<PlayerKVAchievementState>(8,achievements.ResetForKVLoad,achievements.ReplaceFromKVDecoded);
Add<PlayerKVIntMapState>(9,box.ResetForKVLoad,box.ReplaceFromKVDecoded); Add<PlayerKVGuideState>(10,guide.ResetForKVLoad,guide.ReplaceFromKVDecoded);
Add<PlayerKVBattlePassState>(11,battle.ResetForKVLoad,battle.ReplaceFromKVDecoded); Add<PlayerKVMonthlySignState>(12,sign.ResetForKVLoad,sign.ReplaceFromKVDecoded);
Add<PlayerKVTriggerState>(13,trigger.ResetForKVLoad,trigger.ReplaceFromKVDecoded); Add<PlayerKVClubState>(14,club.ResetForKVLoad,club.ReplaceFromKVDecoded);
Add<PlayerKVChallengeState>(15,challenge.ResetForKVLoad,challenge.ReplaceFromKVDecoded); Add<PlayerKVOfferState>(16,chains.ResetForKVLoad,chains.ReplaceFromKVDecoded);
Add<PlayerKVOfferState>(17,disco.ResetForKVLoad,disco.ReplaceFromKVDecoded); Add<PlayerKVPartnerState>(18,partner.ResetForKVLoad,partner.ReplaceFromKVDecoded);
Add<PlayerKVCubeState>(19,cube.ResetForKVLoad,cube.ReplaceFromKVDecoded); Add<PlayerKVEventPackState>(20,eventPack.ResetForKVLoad,eventPack.ReplaceFromKVDecoded);
Add<PlayerKVPiggyBankState>(21,piggy.ResetForKVLoad,piggy.ReplaceFromKVDecoded); Add<PlayerKVBargainState>(22,bargain.ResetForKVLoad,bargain.ReplaceFromKVDecoded);
Add<PlayerKVFootPrintState>(23,foot.ResetForKVLoad,foot.ReplaceFromKVDecoded); Add<PlayerKVShootingRangeState>(24,shooting.ResetForKVLoad,shooting.ReplaceFromKVDecoded);
Add<PlayerKVPotionState>(25,potion.ResetForKVLoad,potion.ReplaceFromKVDecoded); Add<PlayerKVPinballState>(26,pinball.ResetForKVLoad,pinball.ReplaceFromKVDecoded);
Add<PlayerKVScratchState>(27,scratch.ResetForKVLoad,scratch.ReplaceFromKVDecoded);
Add<PlayerKVProgressPackPair>(28,()=>{progress.ResetForKVLoad();migration.ResetForKVLoad();},p=>{progress.ReplaceFromKVDecoded(p.Current);migration.ReplaceFromKVDecoded(p.Migration);});
Add<PlayerKVPinataState>(29,pinata.ResetForKVLoad,pinata.ReplaceFromKVDecoded); Add<PlayerKVEmptyModuleState>(30,digging.ResetForKVLoad,digging.ReplaceFromKVDecoded);
modules.Add(new PlayerKVLoadModule(31,user.ResetForKVLoad,d=>user.ReplaceFromKVDecoded(d.Module<PlayerKVLoginState>(31),d.Epoch,d.Raw)));
return new PlayerKVGameLoadTransaction(modules,System.Threading.Thread.CurrentThread.ManagedThreadId,user.DiscardKVIntents);
}
}
}
#endif

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7803da4435ea45c28007ff0198c676fb

View File

@@ -1,30 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace game.PlayerDataStorage
{
// Only two approved in-memory normalizations. Never a persistence receipt or retry queue.
internal sealed class PlayerKVInitializationIntents
{
readonly Dictionary<string,string> values=new Dictionary<string,string>(StringComparer.Ordinal);
readonly string channel;
internal long Epoch { get; }
internal bool Discarded { get; private set; }
internal int DiscardedCount { get; private set; }
internal int Count => values.Count;
internal IReadOnlyDictionary<string,string> Snapshot => new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(values));
internal PlayerKVInitializationIntents(long epoch,PlayerKVIdentityChannel channel)
{
if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel)) throw new ArgumentException("Unknown intent channel.");
Epoch=epoch; this.channel=channel==PlayerKVIdentityChannel.hwGuest || channel==PlayerKVIdentityChannel.none ? null : channel.ToString();
}
internal void Add(long epoch,string key,string value)
{
if(Discarded || epoch!=Epoch || key==null || (key!="MMTest" && key!=channel) || value==null)
throw new InvalidOperationException("Initialization intent rejected.");
values[key]=value;
}
internal void Discard() { if(Discarded) return; DiscardedCount=values.Count; values.Clear(); Discarded=true; }
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6215961788794950aff0709dbb499793

View File

@@ -58,7 +58,7 @@ namespace game.PlayerDataStorage
internal PlayerKVAccountDescriptor AccountDescriptor => internal PlayerKVAccountDescriptor AccountDescriptor =>
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null; Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
// Only the coordinator may publish this original handle after Apply and Guard release. // Only the coordinator may publish this original handle after preparation and Guard release.
internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle) internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle)
{ {
gameHandle = null; gameHandle = null;
@@ -351,7 +351,13 @@ namespace game.PlayerDataStorage
internal HttpMessageHandler CreateHandler(CookieContainer cookies) internal HttpMessageHandler CreateHandler(CookieContainer cookies)
{ {
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Transport handler required."); return CreateHandler(leaseHandler, cookies, formal);
}
internal static HttpMessageHandler CreateHandler(Func<CookieContainer, HttpMessageHandler> factory,
CookieContainer cookies, bool formal)
{
var handler = factory(cookies) ?? throw new InvalidOperationException("Transport handler required.");
var seen = new HashSet<HttpMessageHandler>(); var seen = new HashSet<HttpMessageHandler>();
var node = handler; var node = handler;
while (node != null) while (node != null)
@@ -359,7 +365,9 @@ namespace game.PlayerDataStorage
// Disposing a cyclic DelegatingHandler graph recursively would overflow the stack. // Disposing a cyclic DelegatingHandler graph recursively would overflow the stack.
// It never becomes a lease; the rejecting factory retains responsibility for that graph. // It never becomes a lease; the rejecting factory retains responsibility for that graph.
if (!seen.Add(node)) if (!seen.Add(node))
{
throw new InvalidOperationException("Cyclic transport is not a validation handler."); throw new InvalidOperationException("Cyclic transport is not a validation handler.");
}
if (!formal && (node is HttpClientHandler || if (!formal && (node is HttpClientHandler ||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")) node.GetType().FullName == "System.Net.Http.SocketsHttpHandler"))
{ {

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@@ -15,16 +16,23 @@ namespace game.PlayerDataStorage
public int? StatusCode { get; } public int? StatusCode { get; }
// Only a successful Get has Data. Failure must never look like an empty save. // Only a successful Get has Data. Failure must never look like an empty save.
public IReadOnlyDictionary<string, string> Data { get; } public IReadOnlyDictionary<string, string> Data { get; }
// Null means the response did not supply timestamp metadata; never substitute read time.
public IReadOnlyDictionary<string, DateTime> UpdatedAtUtc
{
get;
}
// A failed write that entered the transport may already have reached the server. // A failed write that entered the transport may already have reached the server.
// This is conservative, including HTTP failures. Do not blindly replay the write. // This is conservative, including HTTP failures. Do not blindly replay the write.
public bool MayHaveCommitted { get; } public bool MayHaveCommitted { get; }
internal PlayerKVResult(PlayerKVError error, int? statusCode = null, internal PlayerKVResult(PlayerKVError error, int? statusCode = null,
Dictionary<string, string> data = null, bool mayHaveCommitted = false) Dictionary<string, string> data = null, bool mayHaveCommitted = false,
Dictionary<string, DateTime> updatedAtUtc = null)
{ {
Error = error; Error = error;
StatusCode = statusCode; StatusCode = statusCode;
Data = data == null ? null : new ReadOnlyDictionary<string, string>(data); Data = data == null ? null : new ReadOnlyDictionary<string, string>(data);
UpdatedAtUtc = updatedAtUtc == null ? null : new ReadOnlyDictionary<string, DateTime>(updatedAtUtc);
MayHaveCommitted = mayHaveCommitted; MayHaveCommitted = mayHaveCommitted;
} }
} }

View File

@@ -26,8 +26,6 @@ namespace game.PlayerDataStorage
return new PlayerKVRuntimeSelection(false, null); return new PlayerKVRuntimeSelection(false, null);
if (backend != "kv") if (backend != "kv")
throw new ArgumentException("Unsupported save backend.", nameof(backend)); throw new ArgumentException("Unsupported save backend.", nameof(backend));
if (agg)
throw new InvalidOperationException("KV login is not enabled for AGG channels.");
if (!string.Equals(api, FormalApiUrl, StringComparison.Ordinal)) if (!string.Equals(api, FormalApiUrl, StringComparison.Ordinal))
throw new ArgumentException("KV API address does not match the reviewed endpoint.", nameof(api)); throw new ArgumentException("KV API address does not match the reviewed endpoint.", nameof(api));
return new PlayerKVRuntimeSelection(true, new Uri(FormalApiUrl)); return new PlayerKVRuntimeSelection(true, new Uri(FormalApiUrl));

View File

@@ -1,126 +0,0 @@
#if UNITY_EDITOR && !SERVICE_HOST
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using game.Account;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace game.PlayerDataStorage
{
internal static class PlayerKVSaveRouteEditorValidationHost
{
static int running;
// Retain a failed cleanup's owner for diagnosis; never silently restart over it.
static object failedLifetime;
internal static async Task<string> RunAsync(string caseId)
{
if (caseId != "fixed-route") throw new ArgumentException("Unknown fixed validation case.");
if (Interlocked.CompareExchange(ref running, 1, 0) != 0 || failedLifetime != null)
throw new InvalidOperationException("Validation already running or cleanup failed.");
var run = new Run();
try { return await run.Execute(); }
finally
{
try { await run.Close(); }
catch { failedLifetime = run; throw; }
finally { Interlocked.Exchange(ref running, 0); }
}
}
sealed class Run
{
readonly Uri api = new Uri("http://kv-route.invalid/");
readonly List<S101AccountSession> sessions = new List<S101AccountSession>();
readonly Dictionary<string, Dictionary<string, string>> remote = new Dictionary<string, Dictionary<string, string>>
{ ["101"] = new Dictionary<string, string> { ["k"] = "A", ["empty"] = "" }, ["102"] = new Dictionary<string, string> { ["k"] = "B" } };
readonly string cache = Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath,
"../../CodexOutput/upserver/KVSaveRouteTests/unity-runs", DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N")));
PlayerKVGameSessionBridge bridge;
PlayerKVGameLoadGuard guard;
int assertions, auth, gets, writes;
long epoch = 1;
void Check(bool value) { assertions++; if (!value) throw new InvalidOperationException("Fixed route assertion " + assertions); }
static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK)
{ Content = new StringContent(JsonConvert.SerializeObject(value)) };
sealed class SyntheticHandler : HttpMessageHandler
{
readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> send;
internal SyntheticHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) { this.send = send; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) => send(request);
}
HttpMessageHandler KV(CookieContainer jar)
{
var id = jar.GetCookies(api)["sid"].Value;
return new SyntheticHandler(async request =>
{
Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.Port == 80);
var path = request.RequestUri.AbsolutePath;
Check(path == "/playerdata/Get" || path == "/playerdata/Set");
var body = JObject.Parse(await request.Content.ReadAsStringAsync());
if (path == "/playerdata/Get") { gets++; return Json(new { data = remote[id] }); }
writes++;
foreach (var p in ((JObject)body["data"]).Properties()) remote[id][p.Name] = (string)p.Value;
return Json(new { });
});
}
async Task<PlayerKVGameSessionHandle> Open(string id)
{
var session = new S101AccountSession(api, jar => new SyntheticHandler(request =>
{
Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.AbsolutePath == "/account/Login");
auth++; jar.Add(api, new Cookie("sid", id));
return Task.FromResult(Json(new { state = 0, internalId = int.Parse(id), newCreate = false,
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" }));
}));
sessions.Add(session);
Check((await session.LoginAsync(id)).Success);
var opened = await bridge.OpenAsync(session, cache, id, api, epoch, e => e == epoch);
Check(opened.Success); return opened.Handle;
}
internal async Task<string> Execute()
{
// Never disturb a guard held by another validation or a controlled login.
Check(PlayerKVGameLoadGuard.TryEnter(out guard)); guard.Dispose(); guard = null;
Directory.CreateDirectory(cache); bridge = new PlayerKVGameSessionBridge(KV);
var a = await Open("101");
var present = KVMgr.ReadKV(a, "k"); Check(present.Success && present.Found == true && present.Value == "A");
Check(KVMgr.ReadKV(a, "missing").Found == false);
Check(KVMgr.ReadKV(a, "empty").Found == true && KVMgr.ReadKV(a, "empty").Value == "");
Check(KVMgr.ReadKV(a, null).Found == null);
var edit = KVMgr.WriteKV(a, "k", "remote"); Check(edit.Accepted && edit.Ticket != null && writes == 0);
var local = KVMgr.WriteKV(a, "k", "local", false); Check(local.Accepted && local.Ticket == null);
Check(KVMgr.ReadKV(a, "k").Value == "local");
var flush = await KVMgr.FlushKVAsync(a); Check(flush.Confirmed == 1 && remote["101"]["k"] == "remote");
Check((await KVMgr.WaitForKVTicketAsync(a, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed);
await bridge.CloseAsync(); epoch++;
var b = await Open("102"); var writesBefore = writes;
Check(KVMgr.ReadKV(a, "k").Error == PlayerKVKeyReadError.StaleOrClosing);
Check(!KVMgr.WriteKV(a, "k", "late").Accepted);
Check((await KVMgr.FlushKVAsync(a)).Blocked == 1);
Check((await KVMgr.WaitForKVTicketAsync(b, edit.Ticket)).Status == PlayerKVTicketStatus.Blocked);
Check(KVMgr.ReadKV(b, "k").Value == "B" && writes == writesBefore);
Check(PlayerKVGameLoadGuard.TryEnter(out guard));
Check(KVMgr.ReadKV(null, null).Error == PlayerKVKeyReadError.LoadGuardActive);
Check(KVMgr.WriteKV(null, null, null).StateResult.Error == PlayerKVChangeError.Blocked);
Check((await KVMgr.FlushKVAsync(b)).TransportError == PlayerKVError.Cancelled);
Check((await KVMgr.WaitForKVTicketAsync(b, null)).TransportError == PlayerKVError.Cancelled);
Check(guard.Violations == 4 && guard.Count(PlayerKVGuardPoint.ReadLocal) == 1 && guard.Count(PlayerKVGuardPoint.Update) == 1 && guard.Count(PlayerKVGuardPoint.Sync) == 2);
Check(auth == 2 && gets == 2 && writes == 1);
await Close();
return JsonConvert.SerializeObject(new { success = true, assertions, auth, gets, writes, formalRequests = 0, restored = true, cache });
}
internal async Task Close()
{
if (bridge != null) await bridge.CloseAsync();
foreach (var session in sessions) session.Dispose(); sessions.Clear();
guard?.Dispose(); guard = null;
}
}
}
}
#endif

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 860b04f8a2a64eb8acf77e96695f6c54

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3c49515cb90840a189772d23ad69fd2b

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Net; using System.Net;
@@ -76,7 +77,7 @@ namespace game.PlayerDataStorage
internal static async Task<PlayerKVSessionOpenResult> CreateAsync(S101AccountSession session, string cacheRoot, internal static async Task<PlayerKVSessionOpenResult> CreateAsync(S101AccountSession session, string cacheRoot,
string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func<long, bool> isCurrent, string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func<long, bool> isCurrent,
CancellationToken lifetimeToken, Func<CookieContainer, HttpMessageHandler> handlerFactory, CancellationToken lifetimeToken, Func<CookieContainer, HttpMessageHandler> handlerFactory,
TimeSpan? transportTimeout = null) TimeSpan? transportTimeout = null, bool prepareOriginalComparison = false)
{ {
if (session == null || expectedApiBaseUri == null || isCurrent == null || handlerFactory == null || epoch < 0 || if (session == null || expectedApiBaseUri == null || isCurrent == null || handlerFactory == null || epoch < 0 ||
!long.TryParse(expectedPlayerId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0) !long.TryParse(expectedPlayerId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
@@ -112,7 +113,7 @@ namespace game.PlayerDataStorage
using (var lease = owner.TryEnter()) using (var lease = owner.TryEnter())
{ {
if (lease == null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled); if (lease == null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled);
read = await owner.coordinator.InitializeAsync(); read = await owner.coordinator.InitializeAsync(prepareOriginalComparison);
} }
if (!read.Success) if (!read.Success)
return await owner.FailCreate(read.Error == PlayerKVError.Unauthorized return await owner.FailCreate(read.Error == PlayerKVError.Unauthorized
@@ -166,9 +167,49 @@ namespace game.PlayerDataStorage
PlayerKVLocalReadResult ReadFailure(PlayerKVLocalReadError error) => PlayerKVLocalReadResult ReadFailure(PlayerKVLocalReadError error) =>
new PlayerKVLocalReadResult(error, ownerId, PlayerId, Epoch); new PlayerKVLocalReadResult(error, ownerId, PlayerId, Epoch);
public PlayerKVEditResult Update(string key, string value, bool remoteStore = true) public PlayerKVEditResult Update(string key, string value, bool remoteStore = true)
{
return Update(key, value, remoteStore, null);
}
internal PlayerKVStateResult CompareDatas(long nowTicks)
{ {
using (var lease = TryEnter()) using (var lease = TryEnter())
return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore); {
return lease == null ? new PlayerKVStateResult(PlayerKVChangeError.Blocked) : coordinator.CompareDatas(nowTicks);
}
}
internal PlayerKVEditResult Update(string key, string value, bool remoteStore, long? updateTicks)
{
using (var lease = TryEnter())
{
return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore, updateTicks);
}
}
public Task<PlayerKVFlushResult> SyncDataAsync(CancellationToken waitToken = default)
{
var lease = TryEnter();
if (lease == null)
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status));
}
return WaitForFlushAsync(RunOrdinarySyncAsync(lease), waitToken);
}
private async Task<PlayerKVFlushResult> RunOrdinarySyncAsync(OperationLease lease)
{
using (lease)
{
try
{
return await coordinator.SyncDataAsync();
}
catch (Exception)
{
return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Protocol, state.Status);
}
}
} }
public PlayerKVEditResult Delete(string key) public PlayerKVEditResult Delete(string key)
@@ -177,6 +218,30 @@ namespace game.PlayerDataStorage
return lease == null ? ClosedEdit() : coordinator.Delete(key); return lease == null ? ClosedEdit() : coordinator.Delete(key);
} }
public async Task<bool> SyncDataImmidiate(string key, string value)
{
using (var lease = TryEnter())
{
if (lease == null)
{
return false;
}
return await coordinator.SyncDataImmidiate(key, value);
}
}
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{
using (var lease = TryEnter())
{
if (lease == null)
{
return false;
}
return await coordinator.SyncDataImmidiate(values);
}
}
public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default) public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default)
{ {
var lease = TryEnter(); var lease = TryEnter();
@@ -308,7 +373,10 @@ namespace game.PlayerDataStorage
bool IsCapturedOwnerCurrent() bool IsCapturedOwnerCurrent()
{ {
if (lifetime.IsCancellationRequested || !isCurrent(Epoch)) return false; if (lifetime.IsCancellationRequested || !isCurrent(Epoch) || state.AuthenticationInvalid)
{
return false;
}
return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) && return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) &&
session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri; session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri;
} }

View File

@@ -42,11 +42,16 @@ namespace game.PlayerDataStorage
public int Superseded { get; } public int Superseded { get; }
public int Blocked { get; } public int Blocked { get; }
public int Unknown { get; } public int Unknown { get; }
public int Buffered
{
get;
}
public PlayerKVError TransportError { get; } public PlayerKVError TransportError { get; }
public PlayerKVSyncStatus CacheStatus { get; } public PlayerKVSyncStatus CacheStatus { get; }
internal PlayerKVFlushResult(int confirmed, int superseded, int blocked, int unknown, internal PlayerKVFlushResult(int confirmed, int superseded, int blocked, int unknown,
PlayerKVError transportError, PlayerKVSyncStatus cacheStatus) PlayerKVError transportError, PlayerKVSyncStatus cacheStatus, int buffered = 0)
{ {
Buffered = buffered;
Confirmed = confirmed; Superseded = superseded; Blocked = blocked; Unknown = unknown; Confirmed = confirmed; Superseded = superseded; Blocked = blocked; Unknown = unknown;
TransportError = transportError; CacheStatus = cacheStatus; TransportError = transportError; CacheStatus = cacheStatus;
} }
@@ -74,7 +79,9 @@ namespace game.PlayerDataStorage
readonly long epoch; readonly long epoch;
readonly Func<bool> isOwnerCurrent; readonly Func<bool> isOwnerCurrent;
readonly CancellationToken ownerToken; readonly CancellationToken ownerToken;
bool disposed; private bool disposed;
private bool ordinaryDraining;
private TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> ordinaryDrainCompletion;
internal PlayerKVSyncCoordinator(PlayerKVSyncState state, PlayerKVClient client, internal PlayerKVSyncCoordinator(PlayerKVSyncState state, PlayerKVClient client,
System.Net.Http.HttpClient http, string ownerId, long epoch, Func<bool> isOwnerCurrent, System.Net.Http.HttpClient http, string ownerId, long epoch, Func<bool> isOwnerCurrent,
@@ -84,17 +91,21 @@ namespace game.PlayerDataStorage
this.epoch = epoch; this.isOwnerCurrent = isOwnerCurrent; this.ownerToken = ownerToken; this.epoch = epoch; this.isOwnerCurrent = isOwnerCurrent; this.ownerToken = ownerToken;
} }
internal async Task<PlayerKVResult> InitializeAsync() internal async Task<PlayerKVResult> InitializeAsync(bool prepareOriginalComparison = false)
{ {
await dispatch.WaitAsync(ownerToken); await dispatch.WaitAsync(ownerToken);
try try
{ {
if (!Current()) return CancelledResult(); if (!Current()) return CancelledResult();
if (state.HasPendingComparison)
{
return new PlayerKVResult(PlayerKVError.Protocol);
}
var result = await client.GetAsync(null, ownerToken); var result = await client.GetAsync(null, ownerToken);
if (!Current()) return CancelledResult(); if (!Current()) return CancelledResult();
if (result.Success) if (result.Success)
{ {
var reconciled = state.Reconcile(result.Data); var reconciled = state.Reconcile(result.Data, result.UpdatedAtUtc, prepareOriginalComparison);
return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol); return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol);
} }
state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized); state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized);
@@ -103,127 +114,489 @@ namespace game.PlayerDataStorage
finally { dispatch.Release(); } finally { dispatch.Release(); }
} }
internal PlayerKVEditResult Update(string key, string value, bool remoteStore) internal PlayerKVStateResult CompareDatas(long nowTicks)
{ {
var result = state.Update(key, value, remoteStore);
if (!result.Success || !remoteStore) return new PlayerKVEditResult(result);
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
lock (ticketGate) lock (ticketGate)
{ {
SupersedeUnsentForKey(key, result.AttemptSequence); if (!Current())
tickets.Add(ticket.Sequence, new TicketRecord(ticket)); {
return new PlayerKVStateResult(PlayerKVChangeError.Blocked);
}
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
var result = state.CompareDatas(nowTicks);
if (result.Success)
{
CompleteRemovedSources(previous);
}
return result;
}
}
internal PlayerKVEditResult Update(string key, string value, bool remoteStore, long? updateTicks = null)
{
lock (ticketGate)
{
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
var result = state.Update(key, value, remoteStore, updateTicks);
if (!result.Success)
{
return new PlayerKVEditResult(result);
}
CompleteRemovedSources(previous);
if (!remoteStore || !Unsent(state.Inspect()).Any(item => item.Sequence == result.AttemptSequence))
{
return new PlayerKVEditResult(result);
}
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
return new PlayerKVEditResult(result, ticket);
} }
return new PlayerKVEditResult(result, ticket);
} }
internal PlayerKVEditResult Delete(string key) internal PlayerKVEditResult Delete(string key)
{ {
var result = state.Delete(key);
if (!result.Success) return new PlayerKVEditResult(result);
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
lock (ticketGate) lock (ticketGate)
{ {
SupersedeUnsentForKey(key, result.AttemptSequence); var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
var result = state.Delete(key);
if (!result.Success)
{
return new PlayerKVEditResult(result);
}
CompleteRemovedSources(previous);
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
tickets.Add(ticket.Sequence, new TicketRecord(ticket)); tickets.Add(ticket.Sequence, new TicketRecord(ticket));
return new PlayerKVEditResult(result, ticket);
}
}
private static IEnumerable<PlayerKVChange> Unsent(PlayerKVDiagnosticSnapshot value)
{
return value.OrdinaryBuffers.SelectMany(bucket => bucket.Values)
.Concat(value.LegacyPending.Values).Concat(value.ExplicitDeletes.Values);
}
private void CompleteRemovedSources(IEnumerable<long> previous)
{
var remaining = new HashSet<long>(Unsent(state.Inspect()).Select(item => item.Sequence));
foreach (var sequence in previous)
{
if (!remaining.Contains(sequence) && tickets.TryGetValue(sequence, out var record) &&
!record.Dispatched && !record.Completion.Task.IsCompleted)
{
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
}
}
}
internal Task<bool> SyncDataImmidiate(string key, string value)
{
if (!KVSnapshot.ValidKey(key) || value == null)
{
return Task.FromResult(false);
}
return SyncDataImmidiate(new Dictionary<string, string> { [key] = value });
}
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{
if (values == null || values.Count == 0)
{
return false;
}
var data = values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
IReadOnlyList<PlayerKVChange> operations;
lock (ticketGate)
{
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
if (!Current() || !state.PrepareImmediate(data, out operations).Success)
{
return false;
}
CompleteRemovedSources(previous);
}
var acquired = false;
var dispatched = false;
try
{
await dispatch.WaitAsync(ownerToken);
acquired = true;
if (!Current() || state.Status != PlayerKVSyncStatus.Ready)
{
return false;
}
dispatched = true;
var result = await client.SetAsync(data, ownerToken);
if (!Current())
{
return false;
}
if (result.Success)
{
return state.CompleteImmediate(operations).Success;
}
if (result.Error == PlayerKVError.Unauthorized)
{
state.RecordReadFailure(true);
}
return false;
}
catch (Exception)
{
return false;
}
finally
{
if (!dispatched)
{
state.CancelUndispatchedImmediate(operations);
}
if (acquired)
{
dispatch.Release();
}
} }
return new PlayerKVEditResult(result, ticket);
} }
internal long CaptureWatermark(out long[] targetSequences) internal long CaptureWatermark(out long[] targetSequences)
{ {
var next = state.Inspect().NextSequence;
var watermark = next <= 1 ? 0 : next - 1;
lock (ticketGate) lock (ticketGate)
{ {
targetSequences = tickets.Values.Where(record => record.Ticket.Sequence <= watermark && !record.ClaimedByFlush) var snapshot = state.Inspect();
.Select(record => { record.ClaimedByFlush = true; return record.Ticket.Sequence; }).ToArray(); var watermark = snapshot.NextSequence <= 1 ? 0 : snapshot.NextSequence - 1;
var target = new HashSet<long>(Outstanding(snapshot).Where(item => item.Sequence <= watermark).Select(item => item.Sequence));
foreach (var record in tickets.Values)
{
if (record.Ticket.Sequence <= watermark && !record.ClaimedByFlush)
{
record.ClaimedByFlush = true;
target.Add(record.Ticket.Sequence);
}
}
targetSequences = target.ToArray();
return watermark;
} }
return watermark;
} }
internal async Task<PlayerKVFlushResult> FlushThroughAsync(long watermark, long[] targetSequences) private static IEnumerable<PlayerKVChange> Outstanding(PlayerKVDiagnosticSnapshot snapshot)
{ {
PlayerKVError finalError = PlayerKVError.None; return Unsent(snapshot).Concat(snapshot.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
var targetSet = new HashSet<long>(targetSequences ?? Array.Empty<long>()); .Concat(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items))
var recoveredConfirmed = 0; .Concat(snapshot.ActiveBatch == null ? Enumerable.Empty<PlayerKVChange>() : snapshot.ActiveBatch.Items)
var recoveredBlocked = 0; .Concat(snapshot.UnknownBatch == null ? Enumerable.Empty<PlayerKVChange>() : snapshot.UnknownBatch.Items);
var recoveredUnknown = 0; }
// This explicit channel only sends imported legacy intents and explicit deletes, never ordinary buckets.
internal async Task<PlayerKVFlushResult> FlushThroughAsync(long watermark, long[] targetSequences,
bool legacyOnly = false, int maximumBatches = int.MaxValue)
{
var error = PlayerKVError.None;
var observed = new Dictionary<long, PlayerKVTicketStatus>();
var acquired = false; var acquired = false;
try try
{ {
await dispatch.WaitAsync(ownerToken); await dispatch.WaitAsync(ownerToken);
acquired = true; acquired = true;
while (Current()) var sent = 0;
while (Current() && sent < maximumBatches)
{ {
MarkSuperseded(watermark); PlayerKVStateResult begin;
var begin = state.BeginBatch(watermark, out var batch); PlayerKVBatch batch;
lock (ticketGate)
{
begin = state.BeginBatch(watermark, legacyOnly, out batch);
if (begin.Success && batch != null)
{
MarkDispatched(batch);
}
}
if (!begin.Success) if (!begin.Success)
{ {
recoveredBlocked += state.Inspect().Pending.Values.Count(item => MarkExplicitBlocked(watermark, legacyOnly, PlayerKVError.None, observed);
item.Sequence <= watermark && !targetSet.Contains(item.Sequence));
MarkEligibleBlocked(watermark, PlayerKVError.None);
break; break;
} }
if (batch == null) break; if (batch == null)
MarkDispatched(batch); {
PlayerKVResult result; break;
if (batch.IsDelete) }
result = await client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken); sent++;
else var result = await SendAsync(batch);
result = await client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value,
StringComparer.Ordinal), ownerToken);
if (!Current() && result.Success) if (!Current() && result.Success)
{
result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true); result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true);
}
if (result.Success) if (result.Success)
{ {
var confirmed = state.ConfirmBatch(batch.Id); var confirmed = state.ConfirmBatch(batch.Id);
var outcome = confirmed.Success ? PlayerKVTicketStatus.Confirmed : PlayerKVTicketStatus.Unknown;
RecordBatch(batch, outcome, PlayerKVError.None, observed);
if (!confirmed.Success) if (!confirmed.Success)
{ {
CompleteBatch(batch, PlayerKVTicketStatus.Unknown, PlayerKVError.None);
recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
finalError = PlayerKVError.None;
break; break;
} }
CompleteBatch(batch, PlayerKVTicketStatus.Confirmed, PlayerKVError.None);
recoveredConfirmed += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
continue; continue;
} }
error = result.Error;
finalError = result.Error; state.FailBatch(batch.Id, result.MayHaveCommitted, result.Error == PlayerKVError.Unauthorized);
var failed = state.FailBatch(batch.Id, result.MayHaveCommitted, var failedStatus = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
result.Error == PlayerKVError.Unauthorized);
var status = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked; ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked;
CompleteBatch(batch, status, result.Error); RecordBatch(batch, failedStatus, result.Error, observed);
if (status == PlayerKVTicketStatus.Unknown)
recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
else
recoveredBlocked += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
if (!failed.Success && status != PlayerKVTicketStatus.Unknown)
MarkEligibleBlocked(watermark, result.Error);
break; break;
} }
if (!Current()) if (!Current())
{ {
if (finalError == PlayerKVError.None) finalError = PlayerKVError.Cancelled; error = PlayerKVError.Cancelled;
MarkEligibleOnClose(watermark); CloseTickets();
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
finalError = PlayerKVError.Cancelled; error = PlayerKVError.Cancelled;
MarkEligibleOnClose(watermark); CloseTickets();
} }
catch (Exception) catch (Exception)
{ {
finalError = PlayerKVError.Protocol; error = PlayerKVError.Protocol;
MarkEligibleBlocked(watermark, finalError); MarkExplicitBlocked(watermark, legacyOnly, error, observed);
}
finally
{
if (acquired)
{
dispatch.Release();
}
}
return Summary(targetSequences ?? Array.Empty<long>(), error, observed);
}
internal Task<PlayerKVFlushResult> SyncDataAsync()
{
TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> completion;
bool start;
long[] targets;
lock (ticketGate)
{
var snapshot = state.Inspect();
if (!Current())
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status));
}
if (snapshot.LegacyPending.Count != 0)
{
var watermark = snapshot.NextSequence - 1;
return FlushThroughAsync(watermark, snapshot.LegacyPending.Values.Select(item => item.Sequence).ToArray(), true, 1);
}
var captured = state.CaptureOrdinaryBatch(out var batch);
if (!captured.Success)
{
foreach (var item in snapshot.OrdinaryBuffers[0].Values)
{
if (tickets.TryGetValue(item.Sequence, out var record))
{
Complete(record, PlayerKVTicketStatus.Blocked, PlayerKVError.None);
}
}
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.None, state.Status));
}
snapshot = state.Inspect();
if (batch == null && ordinaryDraining)
{
// The existing caller owns the queued work; an empty tick has no new result to observe.
return Task.FromResult(Summary(Array.Empty<long>(), PlayerKVError.None));
}
targets = batch != null ? batch.Items.Select(item => item.Sequence).ToArray() :
snapshot.PreparedOrdinaryBatches.SelectMany(item => item.Items).Select(item => item.Sequence).ToArray();
if (snapshot.PreparedOrdinaryBatches.Count == 0)
{
return Task.FromResult(Summary(targets, PlayerKVError.None));
}
start = !ordinaryDraining;
if (start)
{
ordinaryDraining = true;
ordinaryDrainCompletion = new TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)>(TaskCreationOptions.RunContinuationsAsynchronously);
}
completion = ordinaryDrainCompletion;
}
if (start)
{
// The returned task waits for this actual worker, so the owner's lease covers its finally.
_ = DrainOrdinaryAsync(completion);
}
return ObserveOrdinaryAsync(completion.Task, targets);
}
private async Task<PlayerKVFlushResult> ObserveOrdinaryAsync(Task<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> worker, long[] targets)
{
var completed = await worker;
var error = targets.Where(completed.Results.ContainsKey).Select(sequence => completed.Results[sequence].TransportError)
.FirstOrDefault(value => value != PlayerKVError.None);
if (error == PlayerKVError.None && targets.Any(sequence => !completed.Results.ContainsKey(sequence)))
{
error = completed.Error;
}
return Summary(targets, error, completed.Results.ToDictionary(pair => pair.Key, pair => pair.Value.Status));
}
private async Task DrainOrdinaryAsync(TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> completion)
{
var error = PlayerKVError.None;
var observed = new Dictionary<long, PlayerKVTicketResult>();
void RecordOrdinaryBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError transportError)
{
CompleteBatch(batch, status, transportError);
foreach (var item in batch.Items)
{
observed[item.Sequence] = new PlayerKVTicketResult(status, transportError, state.Status);
}
}
PlayerKVBatch active = null;
var acquired = false;
var dispatched = false;
try
{
while (Current())
{
await dispatch.WaitAsync(ownerToken);
acquired = true;
PlayerKVStateResult begin;
lock (ticketGate)
{
begin = state.BeginOrdinaryBatch(out active);
if (!begin.Success || active == null)
{
// Enqueue and the end-of-drain decision share this lock; no Task.IsCompleted race.
ordinaryDraining = false;
if (!begin.Success)
{
foreach (var item in state.Inspect().PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
{
if (tickets.TryGetValue(item.Sequence, out var record))
{
Complete(record, PlayerKVTicketStatus.Blocked, PlayerKVError.None);
}
}
}
break;
}
MarkDispatched(active);
}
dispatched = true;
var result = await SendAsync(active);
if (!Current() && result.Success)
{
result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true);
}
if (result.Success)
{
var confirmed = state.ConfirmBatch(active.Id);
RecordOrdinaryBatch(active, confirmed.Success ? PlayerKVTicketStatus.Confirmed : PlayerKVTicketStatus.Unknown, PlayerKVError.None);
if (!confirmed.Success)
{
break;
}
}
else
{
error = result.Error;
var failed = state.FailBatch(active.Id, result.MayHaveCommitted, result.Error == PlayerKVError.Unauthorized);
RecordOrdinaryBatch(active, result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked, result.Error);
if (!failed.Success || state.Status != PlayerKVSyncStatus.Ready)
{
break;
}
}
active = null;
dispatched = false;
dispatch.Release();
acquired = false;
}
}
catch (OperationCanceledException)
{
error = PlayerKVError.Cancelled;
if (active != null)
{
state.FailBatch(active.Id, dispatched);
RecordOrdinaryBatch(active, dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled, error);
}
}
catch (Exception)
{
error = PlayerKVError.Protocol;
if (active != null)
{
state.FailBatch(active.Id, dispatched);
RecordOrdinaryBatch(active, dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked, error);
}
}
finally
{
if (acquired)
{
dispatch.Release();
}
lock (ticketGate)
{
if (ReferenceEquals(ordinaryDrainCompletion, completion))
{
ordinaryDraining = false;
ordinaryDrainCompletion = null;
}
try
{
if (!Current())
{
CloseTickets();
}
}
catch (Exception)
{
error = PlayerKVError.Protocol;
}
}
completion.TrySetResult((error, observed));
}
}
private Task<PlayerKVResult> SendAsync(PlayerKVBatch batch)
{
if (batch.IsDelete)
{
return client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken);
}
return client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value, StringComparer.Ordinal), ownerToken);
}
private void RecordBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError error,
Dictionary<long, PlayerKVTicketStatus> observed)
{
CompleteBatch(batch, status, error);
foreach (var item in batch.Items)
{
observed[item.Sequence] = status;
}
}
private void MarkExplicitBlocked(long watermark, bool legacyOnly, PlayerKVError error,
Dictionary<long, PlayerKVTicketStatus> observed)
{
lock (ticketGate)
{
var snapshot = state.Inspect();
var source = legacyOnly ? snapshot.LegacyPending.Values : snapshot.LegacyPending.Values.Concat(snapshot.ExplicitDeletes.Values);
foreach (var item in source.Where(item => item.Sequence <= watermark))
{
var outcome = PlayerKVTicketStatus.Blocked;
if (tickets.TryGetValue(item.Sequence, out var record))
{
outcome = record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked;
Complete(record, outcome, error);
}
observed[item.Sequence] = outcome;
}
} }
finally { if (acquired) dispatch.Release(); }
return Summary(targetSequences ?? Array.Empty<long>(), finalError,
recoveredConfirmed, recoveredBlocked, recoveredUnknown);
} }
internal async Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token) internal async Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token)
@@ -269,60 +642,55 @@ namespace game.PlayerDataStorage
if (tickets.TryGetValue(item.Sequence, out var record)) Complete(record, status, error); if (tickets.TryGetValue(item.Sequence, out var record)) Complete(record, status, error);
} }
void SupersedeUnsentForKey(string key, long newerSequence)
{
foreach (var record in tickets.Values)
if (record.Ticket.Key == key && record.Ticket.Sequence < newerSequence && !record.Dispatched && !record.Completion.Task.IsCompleted)
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
}
void MarkSuperseded(long watermark)
{
var snapshot = state.Inspect();
lock (ticketGate)
foreach (var record in tickets.Values)
if (record.Ticket.Sequence <= watermark && !record.Dispatched && !record.Completion.Task.IsCompleted &&
snapshot.Pending.TryGetValue(record.Ticket.Key, out var latest) && latest.Sequence > record.Ticket.Sequence)
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
}
void MarkEligibleBlocked(long watermark, PlayerKVError error)
{
lock (ticketGate)
foreach (var record in tickets.Values)
if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted)
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked, error);
}
void MarkEligibleOnClose(long watermark)
{
lock (ticketGate)
foreach (var record in tickets.Values)
if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted)
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled,
PlayerKVError.Cancelled);
}
void Complete(TicketRecord record, PlayerKVTicketStatus status, PlayerKVError error) => void Complete(TicketRecord record, PlayerKVTicketStatus status, PlayerKVError error) =>
record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status)); record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status));
PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error, private PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error,
int recoveredConfirmed, int recoveredBlocked, int recoveredUnknown) Dictionary<long, PlayerKVTicketStatus> observed = null)
{ {
var counts = new Dictionary<PlayerKVTicketStatus, int>();
lock (ticketGate) lock (ticketGate)
foreach (var sequence in targetSequences) {
var counts = new Dictionary<PlayerKVTicketStatus, int>();
var snapshot = state.Inspect();
var unknown = new HashSet<long>(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items).Select(item => item.Sequence));
if (snapshot.UnknownBatch != null)
{ {
if (!tickets.TryGetValue(sequence, out var record)) continue; unknown.UnionWith(snapshot.UnknownBatch.Items.Select(item => item.Sequence));
var status = record.Completion.Task.IsCompleted
? record.Completion.Task.GetAwaiter().GetResult().Status : PlayerKVTicketStatus.Pending;
counts[status] = counts.TryGetValue(status, out var count) ? count + 1 : 1;
} }
Func<PlayerKVTicketStatus, int> countOf = status => counts.TryGetValue(status, out var count) ? count : 0; var outstanding = new HashSet<long>(Outstanding(snapshot).Select(item => item.Sequence));
return new PlayerKVFlushResult(countOf(PlayerKVTicketStatus.Confirmed) + recoveredConfirmed, foreach (var sequence in targetSequences.Distinct())
countOf(PlayerKVTicketStatus.Superseded), {
countOf(PlayerKVTicketStatus.Blocked) + countOf(PlayerKVTicketStatus.Cancelled) + recoveredBlocked, PlayerKVTicketStatus outcome;
countOf(PlayerKVTicketStatus.Unknown) + recoveredUnknown, error, state.Status); if (tickets.TryGetValue(sequence, out var record) && record.Completion.Task.IsCompleted)
{
outcome = record.Completion.Task.GetAwaiter().GetResult().Status;
}
else if (observed != null && observed.TryGetValue(sequence, out var actual))
{
outcome = actual;
}
else if (unknown.Contains(sequence))
{
outcome = PlayerKVTicketStatus.Unknown;
}
else if (outstanding.Contains(sequence) || tickets.ContainsKey(sequence))
{
outcome = PlayerKVTicketStatus.Pending;
}
else
{
continue;
}
counts[outcome] = counts.TryGetValue(outcome, out var count) ? count + 1 : 1;
}
int Count(PlayerKVTicketStatus status)
{
return counts.TryGetValue(status, out var count) ? count : 0;
}
return new PlayerKVFlushResult(Count(PlayerKVTicketStatus.Confirmed), Count(PlayerKVTicketStatus.Superseded),
Count(PlayerKVTicketStatus.Blocked) + Count(PlayerKVTicketStatus.Cancelled), Count(PlayerKVTicketStatus.Unknown),
error, state.Status, Count(PlayerKVTicketStatus.Pending));
}
} }
bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent(); bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent();

View File

@@ -65,8 +65,16 @@ namespace game.PlayerDataStorage
public string Id { get; } public string Id { get; }
public IReadOnlyList<PlayerKVChange> Items { get; } public IReadOnlyList<PlayerKVChange> Items { get; }
public bool IsDelete => !Items[0].Target.Exists; public bool IsDelete => !Items[0].Target.Exists;
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items) public bool IsOrdinary
{ Id = id; Items = Array.AsReadOnly(items.ToArray()); } {
get;
}
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items, bool ordinary = false)
{
Id = id;
Items = Array.AsReadOnly(items.ToArray());
IsOrdinary = ordinary;
}
} }
public sealed class PlayerKVDiagnosticSnapshot public sealed class PlayerKVDiagnosticSnapshot
@@ -81,6 +89,34 @@ namespace game.PlayerDataStorage
public IReadOnlyDictionary<string, PlayerKVValue> ConflictObservations { get; } public IReadOnlyDictionary<string, PlayerKVValue> ConflictObservations { get; }
public PlayerKVBatch ActiveBatch { get; } public PlayerKVBatch ActiveBatch { get; }
public PlayerKVBatch UnknownBatch { get; } public PlayerKVBatch UnknownBatch { get; }
public IReadOnlyDictionary<string, string> LocalData
{
get;
}
public IReadOnlyDictionary<string, long> LocalUpdateTicks
{
get;
}
public IReadOnlyList<IReadOnlyDictionary<string, PlayerKVChange>> OrdinaryBuffers
{
get;
}
public IReadOnlyList<PlayerKVBatch> PreparedOrdinaryBatches
{
get;
}
public IReadOnlyList<PlayerKVBatch> OrdinaryUnknownBatches
{
get;
}
public IReadOnlyDictionary<string, PlayerKVChange> LegacyPending
{
get;
}
public IReadOnlyDictionary<string, PlayerKVChange> ExplicitDeletes
{
get;
}
internal PlayerKVDiagnosticSnapshot(KVSnapshot source) internal PlayerKVDiagnosticSnapshot(KVSnapshot source)
{ {
Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline; Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline;
@@ -88,6 +124,13 @@ namespace game.PlayerDataStorage
Baseline = ReadOnly(source.Baseline); LocalOnly = ReadOnly(source.LocalOnly); Baseline = ReadOnly(source.Baseline); LocalOnly = ReadOnly(source.LocalOnly);
Pending = ReadOnly(source.Pending); ConflictObservations = ReadOnly(source.Conflicts); Pending = ReadOnly(source.Pending); ConflictObservations = ReadOnly(source.Conflicts);
ActiveBatch = source.Active; UnknownBatch = source.Unknown; ActiveBatch = source.Active; UnknownBatch = source.Unknown;
LocalData = ReadOnly(source.LocalData);
LocalUpdateTicks = ReadOnly(source.LocalUpdateTicks);
OrdinaryBuffers = Array.AsReadOnly(source.OrdinaryBuffers.Select(bucket => ReadOnly(bucket)).ToArray());
PreparedOrdinaryBatches = Array.AsReadOnly(source.PreparedOrdinaryBatches.ToArray());
OrdinaryUnknownBatches = Array.AsReadOnly(source.OrdinaryUnknownBatches.ToArray());
LegacyPending = ReadOnly(source.LegacyPending);
ExplicitDeletes = ReadOnly(source.ExplicitDeletes);
} }
static IReadOnlyDictionary<string, T> ReadOnly<T>(Dictionary<string, T> source) => static IReadOnlyDictionary<string, T> ReadOnly<T>(Dictionary<string, T> source) =>
new ReadOnlyDictionary<string, T>(new Dictionary<string, T>(source, StringComparer.Ordinal)); new ReadOnlyDictionary<string, T>(new Dictionary<string, T>(source, StringComparer.Ordinal));
@@ -118,38 +161,111 @@ namespace game.PlayerDataStorage
internal long Revision, NextSequence = 1; internal long Revision, NextSequence = 1;
internal bool HasBaseline, AuthenticationInvalid; internal bool HasBaseline, AuthenticationInvalid;
internal Dictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal); internal Dictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal);
internal Dictionary<string, string> LocalOnly = new Dictionary<string, string>(StringComparer.Ordinal); internal Dictionary<string, string> LocalData = new Dictionary<string, string>(StringComparer.Ordinal);
internal Dictionary<string, PlayerKVChange> Pending = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal); internal Dictionary<string, long> LocalUpdateTicks = new Dictionary<string, long>(StringComparer.Ordinal);
// A local origin has no second copy of its value. Ordinary origins can outlive a sent batch.
internal Dictionary<string, (bool LocalOnly, long Sequence)> LocalOrigins =
new Dictionary<string, (bool LocalOnly, long Sequence)>(StringComparer.Ordinal);
internal HashSet<string> ImmediatePreservedKeys = new HashSet<string>(StringComparer.Ordinal);
// Only v1/v2 conversion can populate this lower layer; the first successful Get removes it.
internal Dictionary<string, string> LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(StringComparer.Ordinal);
internal List<Dictionary<string, PlayerKVChange>> OrdinaryBuffers = Enumerable.Range(0, 5)
.Select(_ => new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal)).ToList();
internal List<PlayerKVBatch> PreparedOrdinaryBatches = new List<PlayerKVBatch>();
internal List<PlayerKVBatch> OrdinaryUnknownBatches = new List<PlayerKVBatch>();
internal Dictionary<string, PlayerKVChange> LegacyPending = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
internal Dictionary<string, PlayerKVChange> ExplicitDeletes = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
internal Dictionary<string, PlayerKVValue> Conflicts = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal); internal Dictionary<string, PlayerKVValue> Conflicts = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal);
internal Dictionary<long, PlayerKVChange> RemoteOnlyWrites = new Dictionary<long, PlayerKVChange>();
internal PlayerKVBatch Active, Unknown; internal PlayerKVBatch Active, Unknown;
internal KVSnapshot Clone() => new KVSnapshot {
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid, // Compatibility diagnostics only. A key projection loses cross-bucket sequence history.
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal), LocalOnly = new Dictionary<string, string>(LocalOnly, StringComparer.Ordinal), internal Dictionary<string, PlayerKVChange> Pending => Unsent().GroupBy(item => item.Key, StringComparer.Ordinal)
Pending = new Dictionary<string, PlayerKVChange>(Pending, StringComparer.Ordinal), Conflicts = new Dictionary<string, PlayerKVValue>(Conflicts, StringComparer.Ordinal), .ToDictionary(group => group.Key, group => group.OrderBy(item => item.Sequence).Last(), StringComparer.Ordinal);
Active = Active, Unknown = Unknown internal Dictionary<string, string> LocalOnly => LocalOrigins.Where(pair => pair.Value.LocalOnly && LocalData.ContainsKey(pair.Key))
}; .ToDictionary(pair => pair.Key, pair => LocalData[pair.Key], StringComparer.Ordinal);
internal IEnumerable<PlayerKVChange> Unsent()
{
return OrdinaryBuffers.SelectMany(bucket => bucket.Values).Concat(LegacyPending.Values).Concat(ExplicitDeletes.Values);
}
internal IEnumerable<PlayerKVChange> OrdinaryOutstanding()
{
return OrdinaryBuffers.SelectMany(bucket => bucket.Values)
.Concat(PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
.Concat(Active != null && Active.IsOrdinary ? Active.Items : Enumerable.Empty<PlayerKVChange>());
}
internal KVSnapshot Clone()
{
return new KVSnapshot
{
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid,
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal),
LocalData = new Dictionary<string, string>(LocalData, StringComparer.Ordinal),
LocalUpdateTicks = new Dictionary<string, long>(LocalUpdateTicks, StringComparer.Ordinal),
LocalOrigins = new Dictionary<string, (bool LocalOnly, long Sequence)>(LocalOrigins, StringComparer.Ordinal),
ImmediatePreservedKeys = new HashSet<string>(ImmediatePreservedKeys, StringComparer.Ordinal),
LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(LegacyLocalOnlyUnderImmediate, StringComparer.Ordinal),
OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary<string, PlayerKVChange>(bucket, StringComparer.Ordinal)).ToList(),
PreparedOrdinaryBatches = new List<PlayerKVBatch>(PreparedOrdinaryBatches),
OrdinaryUnknownBatches = new List<PlayerKVBatch>(OrdinaryUnknownBatches),
LegacyPending = new Dictionary<string, PlayerKVChange>(LegacyPending, StringComparer.Ordinal),
ExplicitDeletes = new Dictionary<string, PlayerKVChange>(ExplicitDeletes, StringComparer.Ordinal),
Conflicts = new Dictionary<string, PlayerKVValue>(Conflicts, StringComparer.Ordinal),
Active = Active, Unknown = Unknown,
RemoteOnlyWrites = new Dictionary<long, PlayerKVChange>(RemoteOnlyWrites)
};
}
internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128;
internal string Encode(KVIdentity identity) internal string Encode(KVIdentity identity)
{ {
var pending = new JArray(Pending.Values.OrderBy(p => p.Sequence).Select(ChangeJson));
var conflicts = new JObject(); var conflicts = new JObject();
foreach (var pair in Conflicts) conflicts.Add(pair.Key, ValueJson(pair.Value)); foreach (var pair in Conflicts)
return new JObject { {
["formatVersion"] = 1, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId, conflicts.Add(pair.Key, ValueJson(pair.Value));
}
var origins = new JObject();
foreach (var pair in LocalOrigins)
{
origins.Add(pair.Key, new JObject { ["localOnly"] = pair.Value.LocalOnly, ["sequence"] = pair.Value.Sequence });
}
return new JObject
{
["formatVersion"] = 3, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId,
["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline, ["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline,
["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline), ["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline),
["localOnly"] = JObject.FromObject(LocalOnly), ["pending"] = pending, ["conflictObservations"] = conflicts, ["localData"] = JObject.FromObject(LocalData), ["localUpdateTicks"] = JObject.FromObject(LocalUpdateTicks),
["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown) ["localOrigins"] = origins, ["immediatePreservedKeys"] = new JArray(ImmediatePreservedKeys),
["legacyLocalOnlyUnderImmediate"] = JObject.FromObject(LegacyLocalOnlyUnderImmediate),
["ordinaryBuffers"] = new JArray(OrdinaryBuffers.Select(bucket => (JToken)ChangesJson(bucket.Values))),
["preparedOrdinaryBatches"] = new JArray(PreparedOrdinaryBatches.Select(BatchJson)),
["ordinaryUnknownBatches"] = new JArray(OrdinaryUnknownBatches.Select(BatchJson)),
["legacyPending"] = ChangesJson(LegacyPending.Values), ["explicitDeletes"] = ChangesJson(ExplicitDeletes.Values),
["conflictObservations"] = conflicts, ["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown),
["remoteOnlyWrites"] = ChangesJson(RemoteOnlyWrites.Values)
}.ToString(Formatting.None); }.ToString(Formatting.None);
} }
static JObject ValueJson(PlayerKVValue value) => new JObject { ["exists"] = value.Exists, ["value"] = value.Value }; private static JArray ChangesJson(IEnumerable<PlayerKVChange> values)
static JObject ChangeJson(PlayerKVChange item) => new JObject { {
["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) return new JArray(values.OrderBy(item => item.Sequence).Select(ChangeJson));
}; }
static JToken BatchJson(PlayerKVBatch batch) => batch == null ? JValue.CreateNull() : (JToken)new JObject { private static JObject ValueJson(PlayerKVValue value)
["id"] = batch.Id, ["items"] = new JArray(batch.Items.Select(ChangeJson)) {
}; return new JObject { ["exists"] = value.Exists, ["value"] = value.Value };
}
private static JObject ChangeJson(PlayerKVChange item)
{
return new JObject { ["key"] = item.Key, ["sequence"] = item.Sequence,
["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) };
}
private static JToken BatchJson(PlayerKVBatch batch)
{
if (batch == null)
{
return JValue.CreateNull();
}
return new JObject { ["id"] = batch.Id, ["ordinary"] = batch.IsOrdinary, ["items"] = ChangesJson(batch.Items) };
}
internal static KVSnapshot Decode(string text, KVIdentity identity) internal static KVSnapshot Decode(string text, KVIdentity identity)
{ {
@@ -157,48 +273,268 @@ namespace game.PlayerDataStorage
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
{ {
root = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); root = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error });
if (reader.Read()) throw Invalid(); if (reader.Read())
{
throw Invalid();
}
} }
if (Integer(root, "formatVersion") != 1 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId) throw Invalid(); var version = Integer(root, "formatVersion");
var result = new KVSnapshot { if (version < 1 || version > 3 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId)
{
throw Invalid();
}
var result = new KVSnapshot
{
Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"), Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"),
HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"), HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"),
Baseline = Strings(Field(root, "baseline", JTokenType.Object)), LocalOnly = Strings(Field(root, "localOnly", JTokenType.Object)), Baseline = Strings(Field(root, "baseline", JTokenType.Object)),
Active = Batch(root, "activeBatch"), Unknown = Batch(root, "unknownBatch") Active = Batch(root, "activeBatch", version == 3), Unknown = Batch(root, "unknownBatch", version == 3)
}; };
foreach (var item in Field(root, "pending", JTokenType.Array))
{
var change = Change(item);
if (result.Pending.ContainsKey(change.Key)) throw Invalid();
result.Pending.Add(change.Key, change);
}
foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties()) foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties())
{ {
if (!ValidKey(property.Name)) throw Invalid(); if (!ValidKey(property.Name))
{
throw Invalid();
}
result.Conflicts.Add(property.Name, Value(property.Value)); result.Conflicts.Add(property.Name, Value(property.Value));
} }
if (version >= 2)
{
foreach (var item in Field(root, "remoteOnlyWrites", JTokenType.Array))
{
var change = Change(item);
if (!change.Target.Exists || result.RemoteOnlyWrites.ContainsKey(change.Sequence))
{
throw Invalid();
}
result.RemoteOnlyWrites.Add(change.Sequence, change);
}
}
if (version < 3)
{
DecodeLegacy(root, version, result);
}
else
{
result.LocalData = Strings(Field(root, "localData", JTokenType.Object));
result.LegacyLocalOnlyUnderImmediate = Strings(Field(root, "legacyLocalOnlyUnderImmediate", JTokenType.Object));
foreach (var property in ((JObject)Field(root, "localUpdateTicks", JTokenType.Object)).Properties())
{
if (!ValidKey(property.Name) || property.Value.Type != JTokenType.Integer)
{
throw Invalid();
}
result.LocalUpdateTicks.Add(property.Name, (long)property.Value);
}
foreach (var property in ((JObject)Field(root, "localOrigins", JTokenType.Object)).Properties())
{
if (!ValidKey(property.Name))
{
throw Invalid();
}
result.LocalOrigins.Add(property.Name, (Boolean(property.Value, "localOnly"), Integer(property.Value, "sequence")));
}
foreach (var key in Field(root, "immediatePreservedKeys", JTokenType.Array))
{
if (key.Type != JTokenType.String || !ValidKey((string)key) || !result.ImmediatePreservedKeys.Add((string)key))
{
throw Invalid();
}
}
result.OrdinaryBuffers = Field(root, "ordinaryBuffers", JTokenType.Array).Select(Changes).ToList();
result.LegacyPending = Changes(Field(root, "legacyPending", JTokenType.Array));
result.ExplicitDeletes = Changes(Field(root, "explicitDeletes", JTokenType.Array));
result.PreparedOrdinaryBatches = ReadBatches(root, "preparedOrdinaryBatches");
result.OrdinaryUnknownBatches = ReadBatches(root, "ordinaryUnknownBatches");
}
result.Validate(); result.Validate();
return result; return result;
} }
private static Dictionary<string, PlayerKVChange> Changes(JToken array)
{
if (array.Type != JTokenType.Array)
{
throw Invalid();
}
var values = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
foreach (var token in array)
{
var item = Change(token);
if (values.ContainsKey(item.Key))
{
throw Invalid();
}
values.Add(item.Key, item);
}
return values;
}
private static List<PlayerKVBatch> ReadBatches(JToken root, string name)
{
var result = new List<PlayerKVBatch>();
foreach (var item in Field(root, name, JTokenType.Array))
{
var wrapper = new JObject { ["batch"] = item.DeepClone() };
var batch = Batch(wrapper, "batch", true);
if (batch == null || !batch.IsOrdinary || batch.IsDelete)
{
throw Invalid();
}
result.Add(batch);
}
return result;
}
private static void DecodeLegacy(JObject root, long version, KVSnapshot result)
{
var pending = Changes(Field(root, "pending", JTokenType.Array));
var localOnly = Strings(Field(root, "localOnly", JTokenType.Object));
var overrides = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal);
if (version == 2)
{
foreach (var property in ((JObject)Field(root, "remoteLocalOverrides", JTokenType.Object)).Properties())
{
if (!ValidKey(property.Name))
{
throw Invalid();
}
overrides.Add(property.Name, Value(property.Value));
}
}
// Validate the old shape before converting; new format rules must not accept a corrupt old file.
if (result.Revision < 1 || result.NextSequence < 1 || (result.Active != null && result.Unknown != null) ||
(!result.HasBaseline && (result.Baseline.Count != 0 || localOnly.Count != 0 || pending.Count != 0 ||
result.Active != null || result.Unknown != null || overrides.Count != 0 || result.RemoteOnlyWrites.Count != 0)))
{
throw Invalid();
}
var sequences = new HashSet<long>();
foreach (var item in pending.Values.Concat(result.RemoteOnlyWrites.Values))
{
if (item.Sequence < 1 || item.Sequence >= result.NextSequence || !sequences.Add(item.Sequence))
{
throw Invalid();
}
}
if (result.Conflicts.Keys.Any(key => !pending.ContainsKey(key)))
{
throw Invalid();
}
var oldBatch = result.Active ?? result.Unknown;
if (oldBatch != null)
{
foreach (var item in oldBatch.Items)
{
var hasPending = pending.TryGetValue(item.Key, out var latest);
var hasSource = result.RemoteOnlyWrites.Values.Any(source => source.Key == item.Key && source.Sequence > item.Sequence);
var same = hasPending && latest.Sequence == item.Sequence && latest.BaseValue.Equals(item.BaseValue) && latest.Target.Equals(item.Target);
if (item.Sequence < 1 || item.Sequence >= result.NextSequence || (sequences.Contains(item.Sequence) && !same) ||
(!hasPending && !hasSource) || (hasPending && (latest.Sequence < item.Sequence || !latest.BaseValue.Equals(item.BaseValue) ||
(latest.Sequence == item.Sequence && !same))))
{
throw Invalid();
}
sequences.Add(item.Sequence);
}
}
result.LegacyPending = pending;
result.LocalData = new Dictionary<string, string>(result.Baseline, StringComparer.Ordinal);
foreach (var item in pending.Values)
{
ApplyValue(result.LocalData, item.Key, item.Target);
result.LocalOrigins[item.Key] = (false, item.Sequence);
}
foreach (var pair in localOnly)
{
result.LocalData[pair.Key] = pair.Value;
// Old local-only writes had no stored sequence; zero is permitted only for this converted origin.
result.LocalOrigins[pair.Key] = (true, 0);
}
foreach (var pair in overrides)
{
ApplyValue(result.LocalData, pair.Key, pair.Value);
result.ImmediatePreservedKeys.Add(pair.Key);
if (localOnly.TryGetValue(pair.Key, out var lower))
{
result.LegacyLocalOnlyUnderImmediate[pair.Key] = lower;
}
}
}
private static void ApplyValue(Dictionary<string, string> values, string key, PlayerKVValue value)
{
if (value.Exists)
{
values[key] = value.Value;
}
else
{
values.Remove(key);
}
}
internal void Validate() internal void Validate()
{ {
if (Revision < 1 || NextSequence < 1 || (Active != null && Unknown != null) || if (Revision < 1 || NextSequence < 1 || OrdinaryBuffers.Count != 5 ||
(!HasBaseline && (Baseline.Count != 0 || LocalOnly.Count != 0 || Pending.Count != 0 || Active != null || Unknown != null))) throw Invalid(); (Active != null && Unknown != null) || (Unknown != null && Unknown.IsOrdinary) ||
var sequences = new Dictionary<long, string>(); OrdinaryBuffers.Any(bucket => bucket.Count > 10 || bucket.Values.Any(item => !item.Target.Exists)) ||
foreach (var item in Pending.Values) ExplicitDeletes.Values.Any(item => item.Target.Exists) ||
(!HasBaseline && (Baseline.Count != 0 || LocalData.Count != 0 || Unsent().Any() || Active != null || Unknown != null ||
PreparedOrdinaryBatches.Count != 0 || OrdinaryUnknownBatches.Count != 0 || RemoteOnlyWrites.Count != 0 ||
LocalOrigins.Count != 0 || LocalUpdateTicks.Count != 0 || ImmediatePreservedKeys.Count != 0 || LegacyLocalOnlyUnderImmediate.Count != 0)))
{ {
if (item.Sequence < 1 || item.Sequence >= NextSequence || sequences.ContainsKey(item.Sequence)) throw Invalid(); throw Invalid();
sequences.Add(item.Sequence, item.Key);
} }
foreach (var key in Conflicts.Keys) if (!Pending.ContainsKey(key)) throw Invalid(); var sequences = new Dictionary<long, PlayerKVChange>();
var batch = Active ?? Unknown; void Add(PlayerKVChange item, bool sourceReference = false)
if (batch == null) return;
foreach (var item in batch.Items)
{ {
if (item.Sequence < 1 || item.Sequence >= NextSequence || !Pending.TryGetValue(item.Key, out var latest) || latest.Sequence < item.Sequence || if (!ValidKey(item.Key) || item.Sequence < 1 || item.Sequence >= NextSequence)
(sequences.TryGetValue(item.Sequence, out var key) && key != item.Key) || {
!latest.BaseValue.Equals(item.BaseValue) || throw Invalid();
(latest.Sequence == item.Sequence && !latest.Target.Equals(item.Target))) throw Invalid(); }
if (sequences.TryGetValue(item.Sequence, out var prior))
{
if (!sourceReference || prior.Key != item.Key || !prior.BaseValue.Equals(item.BaseValue) || !prior.Target.Equals(item.Target))
{
throw Invalid();
}
}
else
{
sequences.Add(item.Sequence, item);
}
}
foreach (var item in Unsent().Concat(RemoteOnlyWrites.Values))
{
Add(item);
}
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var batch in PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Concat(new[] { Active, Unknown }).Where(batch => batch != null))
{
if (!Guid.TryParseExact(batch.Id, "N", out _) || !ids.Add(batch.Id) || batch.Items.Count < 1 || batch.Items.Count > 10 ||
batch.Items.Select(item => item.Key).Distinct(StringComparer.Ordinal).Count() != batch.Items.Count ||
batch.Items.Any(item => item.Target.Exists != batch.Items[0].Target.Exists) || (batch.IsOrdinary && batch.IsDelete) ||
!batch.Items.Select(item => item.Sequence).SequenceEqual(batch.Items.Select(item => item.Sequence).OrderBy(value => value)))
{
throw Invalid();
}
foreach (var item in batch.Items)
{
var reference = !batch.IsOrdinary && (LegacyPending.Values.Concat(ExplicitDeletes.Values)
.Any(source => source.Sequence == item.Sequence));
Add(item, reference);
}
}
if (RemoteOnlyWrites.Any(pair => pair.Key != pair.Value.Sequence || !pair.Value.Target.Exists) ||
PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Any(batch => !batch.IsOrdinary) ||
LocalOrigins.Any(pair => !ValidKey(pair.Key) || pair.Value.Sequence < 0 || pair.Value.Sequence >= NextSequence ||
(!pair.Value.LocalOnly && pair.Value.Sequence == 0)) ||
LocalUpdateTicks.Any(pair => !ValidKey(pair.Key) || pair.Value < 0 || pair.Value > DateTime.MaxValue.Ticks) ||
ImmediatePreservedKeys.Any(key => !ValidKey(key)) ||
LegacyLocalOnlyUnderImmediate.Any(pair => !ImmediatePreservedKeys.Contains(pair.Key) ||
!LocalOrigins.TryGetValue(pair.Key, out var origin) || !origin.LocalOnly) ||
Conflicts.Keys.Any(key => !Unsent().Any(item => item.Key == key) && !PreparedOrdinaryBatches.Any(batch => batch.Items.Any(item => item.Key == key)) &&
Unknown == null && OrdinaryUnknownBatches.Count == 0))
{
throw Invalid();
} }
} }
static Dictionary<string, string> Strings(JToken value) static Dictionary<string, string> Strings(JToken value)
@@ -223,7 +559,7 @@ namespace game.PlayerDataStorage
var value = Field(item, "value", exists ? JTokenType.String : JTokenType.Null); var value = Field(item, "value", exists ? JTokenType.String : JTokenType.Null);
return exists ? PlayerKVValue.Present((string)value) : PlayerKVValue.Missing; return exists ? PlayerKVValue.Present((string)value) : PlayerKVValue.Missing;
} }
static PlayerKVBatch Batch(JToken parent, string name) static PlayerKVBatch Batch(JToken parent, string name, bool withKind = false)
{ {
var node = parent[name]; var node = parent[name];
if (node == null) throw Invalid(); if (node == null) throw Invalid();
@@ -234,7 +570,7 @@ namespace game.PlayerDataStorage
if (items.Length == 0 || items.Length > 10 || items.Select(i => i.Key).Distinct(StringComparer.Ordinal).Count() != items.Length || if (items.Length == 0 || items.Length > 10 || items.Select(i => i.Key).Distinct(StringComparer.Ordinal).Count() != items.Length ||
items.Select(i => i.Sequence).Distinct().Count() != items.Length || items.Any(i => i.Target.Exists != items[0].Target.Exists) || items.Select(i => i.Sequence).Distinct().Count() != items.Length || items.Any(i => i.Target.Exists != items[0].Target.Exists) ||
!items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s))) throw Invalid(); !items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s))) throw Invalid();
return new PlayerKVBatch(id, items); return new PlayerKVBatch(id, items, withKind && Boolean(node, "ordinary"));
} }
static JToken Field(JToken parent, string name, JTokenType type) static JToken Field(JToken parent, string name, JTokenType type)
{ {

View File

@@ -13,10 +13,38 @@ namespace game.PlayerDataStorage
/// </summary> /// </summary>
public sealed class PlayerKVSyncState : IDisposable public sealed class PlayerKVSyncState : IDisposable
{ {
readonly object gate = new object(); private readonly object gate = new object();
readonly PlayerKVCacheStore store; private readonly PlayerKVCacheStore store;
KVSnapshot snapshot; private KVSnapshot snapshot;
PlayerKVSyncStatus status; private PlayerKVSyncStatus status;
private readonly HashSet<long> recoveredImmediate;
private bool recoverImmediateView = true;
private bool recoveredOrdinaryUnknown;
private KVSnapshot comparisonLocal;
private Dictionary<string, string> comparisonRemote;
private Dictionary<string, DateTime> comparisonTimes;
private Dictionary<string, string> comparisonView;
private HashSet<string> comparisonImmediateKeys;
internal bool HasPendingComparison
{
get
{
lock (gate)
{
return comparisonLocal != null;
}
}
}
internal bool AuthenticationInvalid
{
get
{
lock (gate)
{
return snapshot.AuthenticationInvalid;
}
}
}
public PlayerKVSyncStatus Status { get { lock (gate) return status; } } public PlayerKVSyncStatus Status { get { lock (gate) return status; } }
public string ApiBaseUri { get; } public string ApiBaseUri { get; }
public string PlayerId { get; } public string PlayerId { get; }
@@ -24,12 +52,26 @@ namespace game.PlayerDataStorage
PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot snapshot) PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot snapshot)
{ {
ApiBaseUri = identity.Api; PlayerId = identity.PlayerId; this.store = store; this.snapshot = snapshot; ApiBaseUri = identity.Api; PlayerId = identity.PlayerId; this.store = store; this.snapshot = snapshot;
recoveredImmediate = new HashSet<long>(snapshot.RemoteOnlyWrites.Keys);
// Authentication belongs to the old owner. A new owner must obtain a fresh // Authentication belongs to the old owner. A new owner must obtain a fresh
// successful Get before Ready; this never clears a persisted unknown batch. // successful Get before Ready; this never clears a persisted unknown batch.
snapshot.AuthenticationInvalid = false; snapshot.AuthenticationInvalid = false;
// A recovered active batch might have reached the server. Never acknowledge it by guessing. // A recovered active batch might have reached the server. Never acknowledge it by guessing.
if (snapshot.Active != null) { snapshot.Unknown = snapshot.Active; snapshot.Active = null; } if (snapshot.Active != null)
status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded; {
if (snapshot.Active.IsOrdinary)
{
snapshot.OrdinaryUnknownBatches.Add(snapshot.Active);
}
else
{
snapshot.Unknown = snapshot.Active;
}
snapshot.Active = null;
}
recoveredOrdinaryUnknown = snapshot.OrdinaryUnknownBatches.Count != 0;
status = snapshot.Unknown != null || recoveredOrdinaryUnknown
? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded;
} }
public static PlayerKVOpenResult Open(string root, Uri api, string playerId) => Open(root, api, playerId, null); public static PlayerKVOpenResult Open(string root, Uri api, string playerId) => Open(root, api, playerId, null);
@@ -50,39 +92,307 @@ namespace game.PlayerDataStorage
lock (gate) lock (gate)
{ {
if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready."); if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready.");
var result = new Dictionary<string, string>(snapshot.Baseline, StringComparer.Ordinal); return new ReadOnlyDictionary<string, string>(ReadView());
foreach (var item in snapshot.Pending.Values) Apply(result, item.Key, item.Target);
foreach (var pair in snapshot.LocalOnly) result[pair.Key] = pair.Value;
return new ReadOnlyDictionary<string, string>(result);
} }
} }
// The caller must supply a successful complete Get. Failed reads must use RecordReadFailure. // The caller must supply a successful complete Get. Failed reads must use RecordReadFailure.
public PlayerKVStateResult Reconcile(IReadOnlyDictionary<string, string> remote) public PlayerKVStateResult Reconcile(IReadOnlyDictionary<string, string> remote)
{
return Reconcile(remote, null, false);
}
internal PlayerKVStateResult Reconcile(IReadOnlyDictionary<string, string> remote,
IReadOnlyDictionary<string, DateTime> updatedAtUtc, bool prepareOriginalComparison)
{ {
lock (gate) lock (gate)
{ {
if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid) return Blocked(); if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid || comparisonLocal != null)
if (remote == null || remote.Any(p => !KVSnapshot.ValidKey(p.Key) || p.Value == null)) return Invalid(); {
return Blocked();
}
if (remote == null || remote.Any(pair => !KVSnapshot.ValidKey(pair.Key) || pair.Value == null))
{
return Invalid();
}
if (prepareOriginalComparison && (!recoverImmediateView || updatedAtUtc == null ||
updatedAtUtc.Count != remote.Count || remote.Keys.Any(key => !updatedAtUtc.ContainsKey(key)) ||
updatedAtUtc.Values.Any(time => time.Kind != DateTimeKind.Utc)))
{
return Invalid();
}
var original = snapshot;
var copy = snapshot.Clone(); var copy = snapshot.Clone();
copy.Conflicts.Clear(); copy.Conflicts.Clear();
if (copy.Unknown != null) if (copy.Unknown != null || (recoveredOrdinaryUnknown && !prepareOriginalComparison))
{ {
foreach (var item in copy.Pending.Values) copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key); foreach (var item in copy.Unsent())
{
copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key);
}
return Commit(copy, PlayerKVSyncStatus.UnknownOutcome); return Commit(copy, PlayerKVSyncStatus.UnknownOutcome);
} }
foreach (var item in copy.Pending.Values.ToArray()) var recoveringKeys = recoverImmediateView
? new HashSet<string>(copy.ImmediatePreservedKeys, StringComparer.Ordinal)
: new HashSet<string>(StringComparer.Ordinal);
if (recoverImmediateView)
{
foreach (var sequence in recoveredImmediate)
{
var source = copy.RemoteOnlyWrites[sequence];
Rebase(copy, source.Key, PlayerKVValue.At(remote, source.Key));
if (!prepareOriginalComparison)
{
copy.RemoteOnlyWrites.Remove(sequence);
}
}
if (!prepareOriginalComparison)
{
copy.ImmediatePreservedKeys.Clear();
}
}
foreach (var source in Sources(copy))
{
if (prepareOriginalComparison && copy.OrdinaryBuffers.Contains(source))
{
continue;
}
foreach (var item in source.Values.ToArray())
{
var observed = PlayerKVValue.At(remote, item.Key);
if (observed.Equals(item.Target))
{
source.Remove(item.Key);
}
else if (!observed.Equals(item.BaseValue))
{
copy.Conflicts[item.Key] = observed;
}
}
}
foreach (var item in copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
{ {
var observed = PlayerKVValue.At(remote, item.Key); var observed = PlayerKVValue.At(remote, item.Key);
if (observed.Equals(item.Target)) copy.Pending.Remove(item.Key); if (!observed.Equals(item.Target) && !observed.Equals(item.BaseValue))
else if (!observed.Equals(item.BaseValue)) copy.Conflicts[item.Key] = observed; {
copy.Conflicts[item.Key] = observed;
}
} }
copy.Baseline = remote.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); var visible = new Dictionary<string, string>(remote, StringComparer.Ordinal);
foreach (var pair in copy.LocalOrigins.ToArray())
{
if (pair.Value.LocalOnly)
{
if (recoverImmediateView && copy.LegacyLocalOnlyUnderImmediate.TryGetValue(pair.Key, out var lower))
{
visible[pair.Key] = lower;
}
else
{
Apply(visible, pair.Key, PlayerKVValue.At(copy.LocalData, pair.Key));
}
}
else
{
var source = copy.Unsent().Concat(copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
.Concat(copy.OrdinaryUnknownBatches.SelectMany(batch => batch.Items))
.FirstOrDefault(item => item.Sequence == pair.Value.Sequence);
if (!recoverImmediateView)
{
Apply(visible, pair.Key, PlayerKVValue.At(copy.LocalData, pair.Key));
}
else if (source != null)
{
Apply(visible, pair.Key, recoveringKeys.Contains(pair.Key)
? source.Target : PlayerKVValue.At(copy.LocalData, pair.Key));
}
else
{
copy.LocalOrigins.Remove(pair.Key);
}
}
}
// An immediate write preserves its view until a new owner performs its initial Get.
foreach (var key in copy.ImmediatePreservedKeys)
{
if (prepareOriginalComparison && recoveringKeys.Contains(key))
{
continue;
}
Apply(visible, key, PlayerKVValue.At(copy.LocalData, key));
}
if (recoverImmediateView && !prepareOriginalComparison)
{
copy.LegacyLocalOnlyUnderImmediate.Clear();
}
if (!prepareOriginalComparison)
{
copy.LocalData = visible;
}
copy.Baseline = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
copy.HasBaseline = true; copy.HasBaseline = true;
return Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict); var reconciled = Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict);
if (reconciled.Success)
{
recoverImmediateView = false;
if (prepareOriginalComparison)
{
comparisonLocal = original;
comparisonRemote = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
comparisonTimes = updatedAtUtc.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
comparisonView = visible;
comparisonImmediateKeys = new HashSet<string>(StringComparer.Ordinal);
}
else
{
recoveredImmediate.Clear();
}
}
return reconciled;
} }
} }
private Dictionary<string, string> ReadView()
{
if (comparisonLocal == null)
{
return LocalView(snapshot);
}
var view = new Dictionary<string, string>(comparisonView, StringComparer.Ordinal);
foreach (var pair in snapshot.LocalOrigins)
{
if (pair.Value.Sequence >= comparisonLocal.NextSequence)
{
Apply(view, pair.Key, PlayerKVValue.At(snapshot.LocalData, pair.Key));
}
}
return view;
}
internal PlayerKVStateResult CompareDatas(long nowTicks)
{
lock (gate)
{
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null ||
snapshot.Unknown != null || comparisonLocal == null)
{
return Blocked();
}
if ((comparisonLocal.OrdinaryUnknownBatches.Count != 0 && !recoveredOrdinaryUnknown) ||
snapshot.OrdinaryUnknownBatches.Any(batch => !comparisonLocal.OrdinaryUnknownBatches.Any(initial => initial.Id == batch.Id)))
{
return Blocked();
}
if (nowTicks < 0 || nowTicks > DateTime.MaxValue.Ticks)
{
return Invalid();
}
var copy = snapshot.Clone();
// A new login uses the original time comparison, not the old request's outcome.
// Retire only its initial ordinary evidence in the same commit as that comparison.
foreach (var batch in comparisonLocal.OrdinaryUnknownBatches)
{
copy.OrdinaryUnknownBatches.RemoveAll(current => current.Id == batch.Id);
foreach (var item in batch.Items)
{
if (copy.LocalOrigins.TryGetValue(item.Key, out var origin) && origin.Sequence == item.Sequence)
{
copy.LocalOrigins.Remove(item.Key);
}
}
}
var protectedKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in new[] { comparisonLocal, snapshot })
{
protectedKeys.UnionWith(source.PreparedOrdinaryBatches.SelectMany(batch => batch.Items).Select(item => item.Key));
protectedKeys.UnionWith(source.LegacyPending.Keys);
protectedKeys.UnionWith(source.ExplicitDeletes.Keys);
protectedKeys.UnionWith(source.ImmediatePreservedKeys);
protectedKeys.UnionWith(source.RemoteOnlyWrites.Values.Select(item => item.Key));
}
protectedKeys.UnionWith(snapshot.LocalOrigins.Where(pair => pair.Value.Sequence >= comparisonLocal.NextSequence).Select(pair => pair.Key));
var visible = ReadView();
foreach (var key in protectedKeys)
{
Apply(copy.LocalData, key, PlayerKVValue.At(visible, key));
}
foreach (var key in comparisonLocal.LocalData.Keys.Concat(comparisonRemote.Keys).Distinct(StringComparer.Ordinal))
{
if (protectedKeys.Contains(key))
{
continue;
}
var localExists = comparisonLocal.HasBaseline && comparisonLocal.LocalData.TryGetValue(key, out _);
comparisonLocal.LocalData.TryGetValue(key, out var localValue);
var remoteExists = comparisonRemote.TryGetValue(key, out var remoteValue);
var localTime = comparisonLocal.LocalUpdateTicks.TryGetValue(key, out var ticks) ? ticks : 0;
var remoteTime = remoteExists ? comparisonTimes[key].Ticks : 0;
if (!localExists || (remoteExists && remoteTime - localTime > TimeSpan.TicksPerSecond * 60))
{
copy.LocalData[key] = remoteValue;
copy.LocalUpdateTicks[key] = remoteTime;
foreach (var bucket in copy.OrdinaryBuffers)
{
if (bucket.TryGetValue(key, out var current) && comparisonLocal.OrdinaryBuffers.Any(old =>
old.TryGetValue(key, out var item) && item.Sequence == current.Sequence))
{
bucket.Remove(key);
if (copy.LocalOrigins.TryGetValue(key, out var origin) && origin.Sequence == current.Sequence)
{
copy.LocalOrigins.Remove(key);
}
}
}
}
else if (!remoteExists || localTime - remoteTime > TimeSpan.TicksPerSecond * 60)
{
if (copy.NextSequence == long.MaxValue)
{
return Invalid();
}
ApplyUpdate(copy, key, localValue, true, nowTicks);
}
else
{
copy.LocalData[key] = localValue;
copy.LocalUpdateTicks[key] = localTime;
}
}
foreach (var sequence in comparisonLocal.RemoteOnlyWrites.Keys)
{
copy.RemoteOnlyWrites.Remove(sequence);
}
foreach (var key in comparisonLocal.LegacyLocalOnlyUnderImmediate.Keys)
{
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
}
foreach (var key in comparisonLocal.ImmediatePreservedKeys)
{
if (!comparisonImmediateKeys.Contains(key))
{
copy.ImmediatePreservedKeys.Remove(key);
}
}
var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success)
{
recoveredOrdinaryUnknown = false;
ClearComparison();
recoveredImmediate.Clear();
}
return result;
}
}
private void ClearComparison()
{
comparisonLocal = null;
comparisonRemote = null;
comparisonTimes = null;
comparisonView = null;
comparisonImmediateKeys = null;
}
public PlayerKVStateResult RecordReadFailure(bool unauthorized = false) public PlayerKVStateResult RecordReadFailure(bool unauthorized = false)
{ {
lock (gate) lock (gate)
@@ -98,42 +408,177 @@ namespace game.PlayerDataStorage
} }
} }
public PlayerKVStateResult Update(string key, string value, bool remoteStore = true) => public PlayerKVStateResult Update(string key, string value, bool remoteStore = true)
Edit(key, value == null ? null : PlayerKVValue.Present(value), remoteStore); {
public PlayerKVStateResult Delete(string key) => Edit(key, PlayerKVValue.Missing, true); return Update(key, value, remoteStore, null);
}
PlayerKVStateResult Edit(string key, PlayerKVValue target, bool remote) internal PlayerKVStateResult Update(string key, string value, bool remoteStore, long? updateTicks)
{ {
lock (gate) lock (gate)
{ {
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) return Blocked(); if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
if (!KVSnapshot.ValidKey(key) || target == null || snapshot.NextSequence == long.MaxValue) return Invalid();
var copy = snapshot.Clone();
var sequence = copy.NextSequence++;
if (remote)
{ {
var baseline = copy.Pending.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key); return Blocked();
copy.Pending[key] = new PlayerKVChange(key, sequence, baseline, target);
copy.LocalOnly.Remove(key);
} }
else copy.LocalOnly[key] = target.Value; if (!KVSnapshot.ValidKey(key) || value == null || snapshot.NextSequence == long.MaxValue ||
(updateTicks.HasValue && (updateTicks.Value < 0 || updateTicks.Value > DateTime.MaxValue.Ticks)))
{
return Invalid();
}
var copy = snapshot.Clone();
var sequence = ApplyUpdate(copy, key, value, remoteStore, updateTicks);
return Commit(copy, PlayerKVSyncStatus.Ready, sequence); return Commit(copy, PlayerKVSyncStatus.Ready, sequence);
} }
} }
public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch) private static long ApplyUpdate(KVSnapshot copy, string key, string value, bool remoteStore, long? updateTicks)
{
var sequence = copy.NextSequence++;
copy.LocalData[key] = value;
copy.ImmediatePreservedKeys.Remove(key);
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
if (updateTicks.HasValue)
{
copy.LocalUpdateTicks[key] = updateTicks.Value;
}
else
{
copy.LocalUpdateTicks.Remove(key);
}
var acceptedRemote = false;
if (remoteStore)
{
foreach (var bucket in copy.OrdinaryBuffers)
{
if (!bucket.ContainsKey(key) && bucket.Count >= 10)
{
continue;
}
var baseline = bucket.TryGetValue(key, out var previous)
? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key);
bucket[key] = new PlayerKVChange(key, sequence, baseline, PlayerKVValue.Present(value));
copy.LegacyPending.Remove(key);
copy.ExplicitDeletes.Remove(key);
acceptedRemote = true;
break;
}
}
copy.LocalOrigins[key] = (!acceptedRemote, sequence);
return sequence;
}
public PlayerKVStateResult Delete(string key)
{
lock (gate)
{
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (!KVSnapshot.ValidKey(key) || snapshot.NextSequence == long.MaxValue)
{
return Invalid();
}
var copy = snapshot.Clone();
var sequence = copy.NextSequence++;
var prior = copy.ExplicitDeletes.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key);
RemoveSources(copy, key);
copy.ExplicitDeletes[key] = new PlayerKVChange(key, sequence, prior, PlayerKVValue.Missing);
copy.LocalData.Remove(key);
copy.LocalUpdateTicks.Remove(key);
copy.LocalOrigins[key] = (false, sequence);
copy.ImmediatePreservedKeys.Remove(key);
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
return Commit(copy, PlayerKVSyncStatus.Ready, sequence);
}
}
internal PlayerKVStateResult CaptureOrdinaryBatch(out PlayerKVBatch batch)
{ {
lock (gate) lock (gate)
{ {
batch = null; batch = null;
if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) return Blocked(); if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
if (throughSequence < 0) return Invalid(); {
var ordered = snapshot.Pending.Values.Where(p => p.Sequence <= throughSequence).OrderBy(p => p.Sequence).ToArray(); return Blocked();
if (ordered.Length == 0) return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); }
if (snapshot.OrdinaryBuffers[0].Count == 0)
{
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
}
var copy = snapshot.Clone(); var copy = snapshot.Clone();
copy.Active = new PlayerKVBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(p => p.Target.Exists == ordered[0].Target.Exists).Take(10)); var first = copy.OrdinaryBuffers[0];
var captured = new PlayerKVBatch(Guid.NewGuid().ToString("N"), first.Values.OrderBy(item => item.Sequence), true);
copy.PreparedOrdinaryBatches.Add(captured);
copy.OrdinaryBuffers.RemoveAt(0);
copy.OrdinaryBuffers.Add(new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal));
var result = Commit(copy, PlayerKVSyncStatus.Ready); var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success) batch = snapshot.Active; // Never expose a batch until its write-ahead record is durable. if (result.Success)
{
batch = captured;
}
return result;
}
}
internal PlayerKVStateResult BeginOrdinaryBatch(out PlayerKVBatch batch)
{
lock (gate)
{
batch = null;
if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (snapshot.PreparedOrdinaryBatches.Count == 0)
{
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
}
var copy = snapshot.Clone();
copy.Active = copy.PreparedOrdinaryBatches[0];
copy.PreparedOrdinaryBatches.RemoveAt(0);
var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success)
{
batch = snapshot.Active;
}
return result;
}
}
// Explicit requests retain their original source until the outcome is known. Ordinary buckets are never selected here.
public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch)
{
return BeginBatch(throughSequence, false, out batch);
}
internal PlayerKVStateResult BeginBatch(long throughSequence, bool legacyOnly, out PlayerKVBatch batch)
{
lock (gate)
{
batch = null;
if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (throughSequence < 0)
{
return Invalid();
}
var source = legacyOnly ? snapshot.LegacyPending.Values : snapshot.LegacyPending.Values.Concat(snapshot.ExplicitDeletes.Values);
var ordered = source.Where(item => item.Sequence <= throughSequence).OrderBy(item => item.Sequence).ToArray();
if (ordered.Length == 0)
{
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
}
var copy = snapshot.Clone();
copy.Active = new PlayerKVBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(item => item.Target.Exists == ordered[0].Target.Exists).Take(10));
var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success)
{
batch = snapshot.Active;
}
return result; return result;
} }
} }
@@ -142,35 +587,204 @@ namespace game.PlayerDataStorage
{ {
lock (gate) lock (gate)
{ {
if (status != PlayerKVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked(); if (status != PlayerKVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId)
{
return Blocked();
}
var copy = snapshot.Clone(); var copy = snapshot.Clone();
foreach (var item in copy.Active.Items) foreach (var item in copy.Active.Items)
{ {
Apply(copy.Baseline, item.Key, item.Target); Apply(copy.Baseline, item.Key, item.Target);
var current = copy.Pending[item.Key]; if (!copy.Active.IsOrdinary)
if (current.Sequence == item.Sequence) copy.Pending.Remove(item.Key); {
else copy.Pending[item.Key] = current.WithBase(item.Target); foreach (var source in new[] { copy.LegacyPending, copy.ExplicitDeletes })
{
if (source.TryGetValue(item.Key, out var current) && current.Sequence == item.Sequence)
{
source.Remove(item.Key);
}
}
}
Rebase(copy, item.Key, item.Target);
} }
copy.Active = null; copy.Active = null;
return Commit(copy, PlayerKVSyncStatus.Ready); return Commit(copy, PlayerKVSyncStatus.Ready);
} }
} }
// mayHaveCommitted=false is only valid with positive evidence of no dispatch. // A definitely undispatched ordinary batch retires, as the original SDK call did; explicit source references stay.
public PlayerKVStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false) public PlayerKVStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false)
{ {
lock (gate) lock (gate)
{ {
if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked(); if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId)
{
return Blocked();
}
var copy = snapshot.Clone(); var copy = snapshot.Clone();
var ordinary = copy.Active.IsOrdinary;
copy.AuthenticationInvalid |= unauthorized; copy.AuthenticationInvalid |= unauthorized;
if (mayHaveCommitted) copy.Unknown = copy.Active; if (mayHaveCommitted)
{
if (ordinary)
{
copy.OrdinaryUnknownBatches.Add(copy.Active);
}
else
{
copy.Unknown = copy.Active;
}
}
copy.Active = null; copy.Active = null;
return Commit(copy, mayHaveCommitted ? PlayerKVSyncStatus.UnknownOutcome : var next = copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : ordinary ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.ReadFailed;
copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.ReadFailed); if (copy.Unknown != null)
{
next = PlayerKVSyncStatus.UnknownOutcome;
}
return Commit(copy, next);
} }
} }
private static IEnumerable<Dictionary<string, PlayerKVChange>> Sources(KVSnapshot value)
{
return value.OrdinaryBuffers.Concat(new[] { value.LegacyPending, value.ExplicitDeletes });
}
private static void RemoveSources(KVSnapshot value, string key)
{
foreach (var source in Sources(value))
{
source.Remove(key);
}
}
private static void Rebase(KVSnapshot value, string key, PlayerKVValue baseline)
{
foreach (var source in Sources(value))
{
if (source.TryGetValue(key, out var pending))
{
source[key] = pending.WithBase(baseline);
}
}
}
internal PlayerKVStateResult PrepareImmediate(string key, string value, out PlayerKVChange operation)
{
operation = null;
if (!KVSnapshot.ValidKey(key) || value == null)
{
// Preserve the single-key state admission order and validation result.
lock (gate)
{
return status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid ? Blocked() : Invalid();
}
}
var result = PrepareImmediate(new Dictionary<string, string> { [key] = value }, out var operations);
if (result.Success)
{
operation = operations[0];
}
return result;
}
internal PlayerKVStateResult PrepareImmediate(IReadOnlyDictionary<string, string> data,
out IReadOnlyList<PlayerKVChange> operations)
{
lock (gate)
{
operations = null;
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (data == null || data.Count == 0 || data.Count > long.MaxValue - snapshot.NextSequence ||
data.Any(pair => !KVSnapshot.ValidKey(pair.Key) || pair.Value == null))
{
return Invalid();
}
var copy = snapshot.Clone();
var prepared = new List<PlayerKVChange>();
foreach (var pair in data)
{
copy.ImmediatePreservedKeys.Add(pair.Key);
var operation = new PlayerKVChange(pair.Key, copy.NextSequence++,
PlayerKVValue.At(copy.Baseline, pair.Key), PlayerKVValue.Present(pair.Value));
copy.RemoteOnlyWrites.Add(operation.Sequence, operation);
RemoveSources(copy, pair.Key);
prepared.Add(operation);
}
var result = Commit(copy, PlayerKVSyncStatus.Ready, prepared[0].Sequence);
if (result.Success)
{
operations = prepared.AsReadOnly();
if (comparisonLocal != null)
{
comparisonImmediateKeys.UnionWith(data.Keys);
}
}
return result;
}
}
internal PlayerKVStateResult CompleteImmediate(PlayerKVChange operation)
{
return CompleteImmediate(new[] { operation });
}
internal PlayerKVStateResult CompleteImmediate(IReadOnlyList<PlayerKVChange> operations)
{
lock (gate)
{
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null ||
snapshot.Unknown != null || operations.Any(operation => !snapshot.RemoteOnlyWrites.ContainsKey(operation.Sequence)))
{
return Blocked();
}
var copy = snapshot.Clone();
foreach (var operation in operations)
{
Apply(copy.Baseline, operation.Key, operation.Target);
Rebase(copy, operation.Key, operation.Target);
copy.RemoteOnlyWrites.Remove(operation.Sequence);
copy.ImmediatePreservedKeys.Add(operation.Key);
}
return Commit(copy, PlayerKVSyncStatus.Ready);
}
}
internal void CancelUndispatchedImmediate(PlayerKVChange operation)
{
CancelUndispatchedImmediate(new[] { operation });
}
internal void CancelUndispatchedImmediate(IReadOnlyList<PlayerKVChange> operations)
{
lock (gate)
{
if (Terminal)
{
return;
}
var protectedBatches = snapshot.OrdinaryUnknownBatches.Concat(new[] { snapshot.Active, snapshot.Unknown }).Where(batch => batch != null);
var removable = operations.Where(operation => snapshot.RemoteOnlyWrites.ContainsKey(operation.Sequence) &&
!protectedBatches.Any(batch => batch.Items.Any(item => item.Key == operation.Key && item.Sequence < operation.Sequence))).ToArray();
if (removable.Length == 0)
{
return;
}
var copy = snapshot.Clone();
foreach (var operation in removable)
{
copy.RemoteOnlyWrites.Remove(operation.Sequence);
}
Commit(copy, status);
}
}
private static Dictionary<string, string> LocalView(KVSnapshot source)
{
return new Dictionary<string, string>(source.LocalData, StringComparer.Ordinal);
}
bool Terminal => status == PlayerKVSyncStatus.Closed || status == PlayerKVSyncStatus.StorageError; bool Terminal => status == PlayerKVSyncStatus.Closed || status == PlayerKVSyncStatus.StorageError;
PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0) PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0)
{ {
@@ -196,6 +810,7 @@ namespace game.PlayerDataStorage
{ {
if (status == PlayerKVSyncStatus.Closed) return; if (status == PlayerKVSyncStatus.Closed) return;
status = PlayerKVSyncStatus.Closed; status = PlayerKVSyncStatus.Closed;
ClearComparison();
store.Dispose(); store.Dispose();
} }
} }

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -8,11 +8,10 @@ namespace game.PlayerDataStorage
{ {
public sealed class KVPlayerSaveStore : IPlayerSaveStore, IDisposable public sealed class KVPlayerSaveStore : IPlayerSaveStore, IDisposable
{ {
PlayerKVGameSessionHandle handle; private PlayerKVGameSessionHandle handle;
CancellationToken lifetime; private CancellationToken lifetime;
bool bound, disposed; private bool bound, disposed;
readonly object receiptsGate = new object(); private readonly object receiptsGate = new object();
readonly Dictionary<string, PlayerKVSyncTicket> pendingReceipts = new Dictionary<string, PlayerKVSyncTicket>(StringComparer.Ordinal);
public bool IsCurrent => bound && !disposed && !lifetime.IsCancellationRequested && handle.Status != PlayerKVSyncStatus.Closed; public bool IsCurrent => bound && !disposed && !lifetime.IsCancellationRequested && handle.Status != PlayerKVSyncStatus.Closed;
internal void Bind(PlayerKVGameSessionHandle original, CancellationToken loginLifetime) internal void Bind(PlayerKVGameSessionHandle original, CancellationToken loginLifetime)
@@ -27,6 +26,23 @@ namespace game.PlayerDataStorage
bound = true; bound = true;
} }
internal void ReleaseFailedBinding(PlayerKVGameSessionHandle original)
{
lock (receiptsGate)
{
if (!bound)
{
return;
}
if (disposed || !ReferenceEquals(handle, original) || original.Status != PlayerKVSyncStatus.Closed)
{
throw new InvalidOperationException("Failed save binding has not closed.");
}
handle = null;
bound = false;
}
}
public PlayerSaveReadResult Read(string key) public PlayerSaveReadResult Read(string key)
{ {
if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady"); if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady");
@@ -45,94 +61,92 @@ namespace game.PlayerDataStorage
return rejected; return rejected;
} }
var result = KVMgr.WriteKV(handle, key, value, remoteStore); var result = KVMgr.WriteKV(handle, key, value, remoteStore);
if (result.Accepted && result.Ticket != null) pendingReceipts[key] = result.Ticket;
var write = result.Accepted ? new PlayerSaveWriteResult(new PlayerSaveReceipt(this, result.Ticket)) : var write = result.Accepted ? new PlayerSaveWriteResult(new PlayerSaveReceipt(this, result.Ticket)) :
new PlayerSaveWriteResult(result.StateResult.Error.ToString()); new PlayerSaveWriteResult(result.StateResult.Error.ToString());
return write; return write;
} }
} }
public async Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default) public async Task<bool> SyncDataImmidiate(string key, string value)
{ {
if (token.IsCancellationRequested) return Cancelled(); PlayerKVGameSessionHandle original;
var write = Write(key, value); CancellationToken originalLifetime;
if (!write.Accepted) return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, write.Error); Task<bool> save;
return await ConfirmAsync(write.Receipt, token); lock (receiptsGate)
}
public async Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default)
{
if (!IsCurrent || token.IsCancellationRequested) return Cancelled();
if (receipt == null || !ReferenceEquals(receipt.Store, this))
return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, "ForeignReceipt");
if (receipt.Ticket == null) return new PlayerSaveConfirmation(PlayerSaveStatus.LocalOnly);
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
{ {
try if (!IsCurrent)
{ {
await KVMgr.FlushKVAsync(handle, wait.Token); return false;
var ticket = await KVMgr.WaitForKVTicketAsync(handle, (PlayerKVSyncTicket)receipt.Ticket, wait.Token);
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
if (ticket.Status == PlayerKVTicketStatus.Confirmed)
{
ForgetConfirmed((PlayerKVSyncTicket)receipt.Ticket);
return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed);
}
return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown :
ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded :
ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected,
ticket.TransportError.ToString());
} }
catch (OperationCanceledException) { return Cancelled(); } // Preserve the former current-store dictionary lookup's null-key behavior.
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
original = handle;
originalLifetime = lifetime;
save = KVMgr.SyncDataImmidiate(original, key, value);
}
var success = await save;
lock (receiptsGate)
{
return success && IsCurrent && !originalLifetime.IsCancellationRequested && ReferenceEquals(handle, original);
} }
} }
public async Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default) internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{ {
if (!IsCurrent || token.IsCancellationRequested) return Cancelled(); PlayerKVGameSessionHandle original;
PlayerKVSyncTicket[] captured; CancellationToken originalLifetime;
lock (receiptsGate) captured = pendingReceipts.Values.ToArray(); Task<bool> save;
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token)) lock (receiptsGate)
{ {
try if (!IsCurrent || values == null || values.Count == 0)
{ {
var result = await KVMgr.FlushKVAsync(handle, wait.Token); return false;
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
var status = result.Unknown > 0 || result.CacheStatus == PlayerKVSyncStatus.UnknownOutcome ?
PlayerSaveStatus.Unknown : result.Blocked > 0 || result.TransportError != PlayerKVError.None ||
result.CacheStatus != PlayerKVSyncStatus.Ready ? PlayerSaveStatus.Rejected : PlayerSaveStatus.Confirmed;
if (status != PlayerSaveStatus.Confirmed) return new PlayerSaveConfirmation(status, result.TransportError.ToString());
// Multiple writes before this flush are covered by the captured final receipt.
// A replacement during the await belongs to a later flush and cannot confirm it.
foreach (var receipt in captured)
{
var ticket = await KVMgr.WaitForKVTicketAsync(handle, receipt, wait.Token);
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
if (ticket.Status != PlayerKVTicketStatus.Confirmed)
return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded :
ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown :
ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected,
ticket.TransportError.ToString());
ForgetConfirmed(receipt);
}
return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed);
} }
catch (OperationCanceledException) { return Cancelled(); } var data = values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
original = handle;
originalLifetime = lifetime;
save = KVMgr.SyncDataImmidiate(original, data);
}
var success = await save;
lock (receiptsGate)
{
return success && IsCurrent && !originalLifetime.IsCancellationRequested && ReferenceEquals(handle, original);
} }
} }
static PlayerSaveConfirmation Cancelled() => new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled); internal Task<PlayerKVFlushResult> SyncDataAsync()
void ForgetConfirmed(PlayerKVSyncTicket receipt)
{ {
lock (receiptsGate) lock (receiptsGate)
if (pendingReceipts.TryGetValue(receipt.Key, out var latest) && ReferenceEquals(latest, receipt)) {
pendingReceipts.Remove(receipt.Key); var original = handle;
if (!IsCurrent)
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled,
original == null ? PlayerKVSyncStatus.NotLoaded : original.Status));
}
return KVMgr.SyncKVAsync(original);
}
}
public void Dispose()
{
lock (receiptsGate)
{
disposed = true;
}
} }
public void Dispose() { disposed = true; lock (receiptsGate) { pendingReceipts.Clear(); } }
} }
public sealed class PlayFabPlayerSaveStore : IPlayerSaveStore public sealed class PlayFabPlayerSaveStore : IPlayerSaveStore
{ {
public Task<bool> SyncDataImmidiate(string key, string value)
{
return manager.SyncDataImmidiate(key, value);
}
readonly PlayFabMgr manager; readonly PlayFabMgr manager;
readonly CancellationToken lifetime; readonly CancellationToken lifetime;
public PlayFabPlayerSaveStore(IUserService user) : this(PlayFabMgr.Instance, user.LoginLifetime) { } public PlayFabPlayerSaveStore(IUserService user) : this(PlayFabMgr.Instance, user.LoginLifetime) { }
@@ -162,38 +176,5 @@ namespace game.PlayerDataStorage
catch (Exception ex) { return new PlayerSaveWriteResult(ex.GetType().Name); } catch (Exception ex) { return new PlayerSaveWriteResult(ex.GetType().Name); }
} }
public async Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default)
{
if (!IsCurrent || token.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
{
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (wait.Token.Register(() => cancelled.TrySetResult(false)))
{
try
{
var save = manager.SyncDataImmidiate(key, value);
var finished = await Task.WhenAny(save, cancelled.Task);
if (!IsCurrent || wait.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
return new PlayerSaveConfirmation(await finished ? PlayerSaveStatus.Confirmed : PlayerSaveStatus.Rejected);
}
catch (Exception ex) { return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, ex.GetType().Name); }
}
}
}
public Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default) =>
Task.FromResult(new PlayerSaveConfirmation(!IsCurrent || token.IsCancellationRequested ? PlayerSaveStatus.Cancelled :
receipt == null || !ReferenceEquals(receipt.Store, this) ? PlayerSaveStatus.Rejected :
receipt.Ticket == null ? PlayerSaveStatus.LocalOnly : PlayerSaveStatus.Buffered));
public Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default)
{
if (!IsCurrent || token.IsCancellationRequested)
return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled));
// The original manager owns its timer and callback-based buffered dispatch.
manager.FlushBufferedData();
return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Buffered));
}
} }
} }

View File

@@ -68,19 +68,34 @@ namespace game
} }
internal bool HasFormalPromptOwner(PlayerKVGameSessionHandle owner) => internal bool HasFormalPromptOwner(PlayerKVGameSessionHandle owner) =>
requiresFormalKV && ReferenceEquals(promptOwner, owner); requiresFormalKV && ReferenceEquals(promptOwner, owner);
internal void ReleaseFailedPromptOwner(PlayerKVGameSessionHandle owner)
{
if (promptOwner == null)
{
return;
}
if (!ReferenceEquals(promptOwner, owner) || owner.Status != PlayerKVSyncStatus.Closed)
{
throw PromptFailure("ShowNotice: failed owner has not closed.");
}
promptOwner = null;
LastPromptEdit = null;
_showNotice = null;
}
internal void RequirePromptOwner() internal void RequirePromptOwner()
{ {
if (promptOwner != null) ReadPrompt(); if (promptOwner != null) ReadPrompt();
else if (requiresFormalKV) throw PromptFailure("ShowNotice: formal owner not handed off."); else if (requiresFormalKV) throw PromptFailure("ShowNotice: formal owner not handed off.");
} }
PlayerKVKeyReadResult ReadPrompt() private PlayerKVKeyReadResult ReadPrompt()
{ {
if (LastPromptEdit != null && !LastPromptEdit.Accepted)
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
var read = KVMgr.ReadKV(promptOwner, "ShowNotice"); var read = KVMgr.ReadKV(promptOwner, "ShowNotice");
if (!read.Success) if (!read.Success)
{
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {read.Error}"); throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {read.Error}");
}
return read; return read;
} }
@@ -162,11 +177,14 @@ namespace game
{ {
get get
{ {
if (requiresFormalKV && promptOwner == null) RequirePromptOwner(); if (requiresFormalKV && promptOwner == null)
{
RequirePromptOwner();
}
if (promptOwner != null) if (promptOwner != null)
{ {
var read = ReadPrompt(); var read = ReadPrompt();
_showNotice = read.Found == true && read.Value == "1"; _showNotice ??= read.Found == true && read.Value == "1";
return _showNotice.Value; return _showNotice.Value;
} }
_showNotice ??= PlayFabMgr.Instance.GetLocalData("ShowNotice") == "1"; _showNotice ??= PlayFabMgr.Instance.GetLocalData("ShowNotice") == "1";
@@ -174,19 +192,23 @@ namespace game
} }
set set
{ {
if (requiresFormalKV && promptOwner == null) RequirePromptOwner(); if (requiresFormalKV && promptOwner == null)
{
RequirePromptOwner();
}
if (promptOwner != null) if (promptOwner != null)
{ {
var read = ReadPrompt(); ReadPrompt();
var serialized = value ? "1" : "0"; if (value != _showNotice)
if (read.Found != true || read.Value != serialized)
{ {
LastPromptEdit = KVMgr.WriteKV(promptOwner, "ShowNotice", serialized); _showNotice = value;
LastPromptEdit = KVMgr.WriteKV(promptOwner, "ShowNotice", value ? "1" : "0");
if (!LastPromptEdit.Accepted) if (!LastPromptEdit.Accepted)
{
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}"); throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
}
} }
RequirePromptOwner(); RequirePromptOwner();
_showNotice = value;
return; return;
} }
if (value != _showNotice) if (value != _showNotice)

View File

@@ -25,7 +25,9 @@ namespace game
{ {
titleNewsEvent.titleNewsItem = News; titleNewsEvent.titleNewsItem = News;
bool isRod = false; bool isRod = false;
GetTitleNewsResult result = await PlayFabClientAsyncAPI.GetTitleNewsAsync(new PlayFab.ClientModels.GetTitleNewsRequest()); GetTitleNewsResult result = GContext.container.Resolve<IUserService>() is KVUserService
? await GetKVTitleNewsAsync()
: await PlayFabClientAsyncAPI.GetTitleNewsAsync(new PlayFab.ClientModels.GetTitleNewsRequest());
if (result != null && result.News != null && result.News.Count > 0) if (result != null && result.News != null && result.News.Count > 0)
{ {
News = result.News[0]; News = result.News[0];
@@ -57,5 +59,34 @@ namespace game
RedPointManager.Instance.SetRedPointState(RedPointName.Announcement, isRod); RedPointManager.Instance.SetRedPointState(RedPointName.Announcement, isRod);
} }
private async Task<GetTitleNewsResult> GetKVTitleNewsAsync()
{
string body = await GContext.container.Resolve<ICustomServerMgr>().CommonDataRequest("GetTitleNews", new { });
if (string.IsNullOrEmpty(body))
{
return null;
}
try
{
var response = Newtonsoft.Json.JsonConvert.DeserializeObject<KvTitleNewsResponse>(body);
if (response == null || response.Code != 1 || response.News == null ||
(response.News.Count > 0 && response.News[0] == null))
{
return null;
}
return new GetTitleNewsResult { News = response.News };
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogWarning("KV title news response: " + ex.GetType().Name);
return null;
}
}
private sealed class KvTitleNewsResponse
{
public int Code;
public System.Collections.Generic.List<TitleNewsItem> News;
}
} }
} }

View File

@@ -1,4 +1,4 @@
#if UNITY_EDITOR && !SERVICE_HOST #if UNITY_EDITOR
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
@@ -152,9 +152,6 @@ namespace game.PlayerDataStorage
CheckRewardEnvironment(p.Data,false); CheckRewardEnvironment(p.Data,false);
if(p.Activities!=ActivityStamp(GContext.container.Resolve<Tables>())) throw Stop("Activity changed during fishing reward."); if(p.Activities!=ActivityStamp(GContext.container.Resolve<Tables>())) throw Stop("Activity changed during fishing reward.");
} }
internal static bool RewardRunCurrent => RewardCurrent(rewardAction);
internal static bool RewardCanAdvance => !Enabled || (FishingCurrent(fishingAction) &&
(rewardAction==null || (!rewardAction.Cancelled && !rewardAction.Sealed)));
internal static bool BeginRewardDrawing(object controller) internal static bool BeginRewardDrawing(object controller)
{ {
if(Permit?.AllowsReward!=true || !FishingCurrent(fishingAction) || !fishingAction.Confirmed || !fishingAction.Continued) if(Permit?.AllowsReward!=true || !FishingCurrent(fishingAction) || !fishingAction.Confirmed || !fishingAction.Continued)
@@ -187,7 +184,6 @@ namespace game.PlayerDataStorage
} }
internal static bool RewardCurrent(FishingRewardAction a) => a!=null && ReferenceEquals(a,rewardAction) && internal static bool RewardCurrent(FishingRewardAction a) => a!=null && ReferenceEquals(a,rewardAction) &&
!a.Cancelled && FishingCurrent(a.Consumption); !a.Cancelled && FishingCurrent(a.Consumption);
internal static bool RewardPanelCurrent(FishingRewardAction a,object panel) => RewardCurrent(a) && ReferenceEquals(a.Panel,panel);
sealed class RewardSegment : IDisposable sealed class RewardSegment : IDisposable
{ {
readonly FishingRewardAction action; readonly FishingRewardAction action;
@@ -222,17 +218,6 @@ namespace game.PlayerDataStorage
else if(key!="NewPlayerDataDic" && key!=a.Plan.MapKey && !rewardTaskKeys.Contains(key)) else if(key!="NewPlayerDataDic" && key!=a.Plan.MapKey && !rewardTaskKeys.Contains(key))
throw Stop("Unexpected fishing reward key: "+key); throw Stop("Unexpected fishing reward key: "+key);
} }
internal static void CheckCaughtFishSave(int fish,int count)
{
var a=rewardAction;
if(!RewardCurrent(a) || a.SegmentName!="reward" || a.Plan.Fish!=fish ||
count!=checked(a.BeforeFishCount+GContext.container.Resolve<PlayerData>().GetMagnification()))
throw Stop("Caught fish final save must contain exactly this catch's increment.");
}
internal static void CheckRewardMainSave()
{
if(!RewardCurrent(rewardAction) || rewardAction.SegmentName!="reward") throw Stop("Final reward save outside reward segment.");
}
internal static void RecordRewardGuideNotification() internal static void RecordRewardGuideNotification()
{ {
var a=rewardAction; var a=rewardAction;
@@ -253,9 +238,6 @@ namespace game.PlayerDataStorage
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30))) using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
{ {
var owner=a.Consumption.Owner; var owner=a.Consumption.Owner;
var flush=await KVMgr.FlushKVAsync(owner,timeout.Token);
if(!RewardCurrent(a) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None)
throw Stop("Reward flush: "+flush.TransportError);
foreach(var r in a.Records) foreach(var r in a.Records)
{ {
var t=r.Edit.Ticket;if(t==null)continue; var t=r.Edit.Ticket;if(t==null)continue;

View File

@@ -1,4 +1,4 @@
#if UNITY_EDITOR && !SERVICE_HOST #if UNITY_EDITOR
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
@@ -9,287 +9,691 @@ using asap.core;
using GameCore; using GameCore;
using Newtonsoft.Json; using Newtonsoft.Json;
using UniRx; using UniRx;
using DG.Tweening;
using UnityEngine; using UnityEngine;
namespace game.PlayerDataStorage namespace game.PlayerDataStorage
{ {
internal static partial class PlayerKVGameplayLoop internal static partial class PlayerKVGameplayLoop
{ {
// Observes the original async-void method; never enables the temporary gameplay path.
internal static async Task<string> VerifyOriginalFishingForEditorAsync()
{
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null)
{
throw new InvalidOperationException("Idle editor without a real permit required.");
}
if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
{
throw new InvalidOperationException("Validation running.");
}
var oldContainer = GContext.container;
var oldEvents = FishingContextAccess.Events;
var oldLanguages = LocalizationMgr.enumLanguage;
var fields = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
var textTable = typeof(LocalizationMgr).GetField("table", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
var oldTextTable = textTable.GetValue(null);
var folder = Path.Combine(Root, "fishing-original-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
var passed = new List<string>();
int checks = 0;
void Check(bool value, string message)
{
checks++;
if (!value)
{
throw new InvalidOperationException("Original fishing: " + message);
}
}
try
{
foreach (var scenario in new[] { "ordinary", "retry", "pack-21", "pack-22", "unauthorized", "held-recovery", "below-limit", "subscribers", "cross-limit-boundary" })
{
var errors = new List<Exception>();
var fixture = new EquipmentFixture(Path.Combine(folder, scenario));
KVPlayerSaveStore store = null;
Container container = null;
PlayerData player = null;
AchievementDataManager achievements = null;
FishingSubscribers subscribers = null;
GameObject shell = null;
IDisposable consumeSubscription = null;
IDisposable playerSubscription = null;
bool invoked = false;
int completionLogs = 0;
int saveFailureLogs = 0;
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var unexpectedLogs = new List<string>();
void Log(string condition, string stack, LogType type)
{
const string prefix = "[FishBehaviourB::ConsumeBaitOnPlayfab] ";
if (condition == prefix + "True" || condition == prefix + "False")
{
completionLogs++;
completed.TrySetResult(condition == prefix + "True");
}
if (scenario == "unauthorized" && type == LogType.Error && condition == "[KVMgr::SyncDataImmidiate] Save failed.")
{
saveFailureLogs++;
}
else if (type == LogType.Exception || type == LogType.Error || type == LogType.Assert)
{
unexpectedLogs.Add(condition + "\n" + stack);
}
}
void Clean(Action action)
{
try
{
action();
}
catch (Exception ex)
{
errors.Add(ex);
validationCleanupFailure = fixture;
}
}
void ObserveRecovery(Action startRecovery)
{
var awaitAssembly = typeof(IEnumeratorAwaitExtensions).Assembly;
var staticFields = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic;
var allFields = fields | System.Reflection.BindingFlags.Public;
var syncField = awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil", true).GetField("<UnitySynchronizationContext>k__BackingField", staticFields);
var runnerField = awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner", true).GetField("_instance", staticFields);
var savedSync = syncField.GetValue(null);
var savedRunner = runnerField.GetValue(null);
var savedCurrent = SynchronizationContext.Current;
var observer = new OriginalFishingTestContext();
var scheduler = new OriginalFishingTestContext();
System.Collections.IEnumerator instructionRun = null;
IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter recoveryAwaiter = null;
bool instructionYielded = false;
bool instructionEnded = false;
try
{
SynchronizationContext.SetSynchronizationContext(observer);
syncField.SetValue(null, scheduler);
startRecovery();
Check(scheduler.Count == 1 && observer.Started == 1 && observer.Completed == 0, "original recovery suspended at one instruction");
var posted = scheduler.Peek();
Check(posted.State == null && posted.Callback.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass12_0", "exact original scheduler dispatch");
var action = (Action)posted.Callback.Target.GetType().GetField("action", allFields).GetValue(posted.Callback.Target);
Check(action.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass10_0" && action.Method.Name == "<GetAwaiterReturnVoid>b__0", "exact original void wait closure");
recoveryAwaiter = (IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter)action.Target.GetType().GetField("awaiter", allFields).GetValue(action.Target);
var instruction = action.Target.GetType().GetField("instruction", allFields).GetValue(action.Target);
Check(instruction is WaitForSeconds && !recoveryAwaiter.IsCompleted, "actual incomplete seconds awaiter");
var wrapper = typeof(IEnumeratorAwaitExtensions).GetNestedType("InstructionWrappers", System.Reflection.BindingFlags.NonPublic);
instructionRun = (System.Collections.IEnumerator)wrapper.GetMethod("ReturnVoid", staticFields | System.Reflection.BindingFlags.Public).Invoke(null, new[] { (object)recoveryAwaiter, instruction });
scheduler.Take();
instructionYielded = instructionRun.MoveNext();
Check(instructionYielded && ReferenceEquals(instructionRun.Current, instruction), "library iterator yielded original instruction");
player.StopRecoveryEnergy();
SynchronizationContext.SetSynchronizationContext(scheduler);
instructionEnded = !instructionRun.MoveNext();
Check(instructionEnded && recoveryAwaiter.IsCompleted && observer.Completed == 1, "manual-await-boundary exits original async void");
}
finally
{
Clean(() => player.StopRecoveryEnergy());
Clean(() =>
{
SynchronizationContext.SetSynchronizationContext(scheduler);
if (instructionRun != null && !instructionEnded)
{
if (!instructionYielded)
{
instructionYielded = instructionRun.MoveNext();
}
instructionEnded = !instructionRun.MoveNext();
}
});
Clean(() =>
{
SynchronizationContext.SetSynchronizationContext(observer);
while (observer.Count != 0)
{
var posted = observer.Take();
Clean(() => posted.Callback(posted.State));
}
});
Clean(() => Check(observer.Started == 1 && observer.Completed == 1 && observer.Count == 0 && scheduler.Count == 0 && instructionEnded && recoveryAwaiter != null && recoveryAwaiter.IsCompleted, "recovery state machine and queues drained"));
Clean(() => Check(ReferenceEquals(runnerField.GetValue(null), savedRunner), "no coroutine runner created or replaced"));
Clean(() => syncField.SetValue(null, savedSync));
Clean(() => SynchronizationContext.SetSynchronizationContext(savedCurrent));
}
}
Application.logMessageReceived += Log;
try
{
var owner = await fixture.Open();
store = new KVPlayerSaveStore();
store.Bind(owner, CancellationToken.None);
var tables = new cfg.Tables(n => SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData", n + ".json")))));
Check(tables.TbEnergyDef.DataList.Count > 4 && tables.TbEnergyDef.DataList[4].Mag == 10 && tables.TbEnergyDef.DataList[4].RetryCost == 5, "distinct real normal/retry costs");
container = new Container();
FishingContextAccess.Use(container);
FishingContextAccess.Events = new EventAggregator();
container.RegisterInstance(tables);
container.RegisterInstance<IPlayerSaveStore>(store);
player = new PlayerData(tables, null);
var small = new SmallGameData(tables, null, null);
var fish = new PlayerFishData(tables, null);
achievements = new AchievementDataManager(tables);
var events = new FishingEventData(tables);
container.RegisterInstance(player);
container.RegisterInstance(small);
container.RegisterInstance(fish);
container.RegisterInstance(achievements);
container.RegisterInstance(new FishingBoxData());
container.RegisterInstance(events);
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
player.playerSaveStore = store;
fish.playerSaveStore = store;
small.playerSaveStore = store;
events.playerSaveStore = store;
achievements.Init();
LocalizationMgr.enumLanguage = tables.TbLanguageConfig.DataList.Select(x => x.ID).ToList();
textTable.SetValue(null, null);
int initialEnergy = scenario == "cross-limit-boundary" ? 300 : scenario == "held-recovery" || scenario == "below-limit" ? 250 : 400;
int rod = tables.TbRodData.DataMap.Keys.First();
int map = tables.TbMapData.DataMap.Keys.First();
Module02OriginalLoadTests.LoadCore(player, new object[] { 1, 10UL, 0, initialEnergy, 5, 4, rod, new DateTime(2026, 1, 1), 0, 7, 10UL, 2, map, map, 0, 0, 0, 0, 0 });
typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player, 300);
typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player, 1);
if (scenario == "subscribers")
{
subscribers = new FishingSubscribers();
subscribers.Bind(tables, events, container);
Check(!tables.TbFishingEvent.DataList.Any(e => e.ConditionList.Any(c => c.Condition == cfg.ConditionType.ConsumeEnergy)) && !tables.TbTriggerPackManager.DataList.Any(t => t.Condition == cfg.ConditionType.ConsumeEnergy), "real configuration excludes additional async event and pack activation");
}
player.SavePlayerData(false);
int subtype = scenario == "pack-21" ? 21 : scenario == "pack-22" ? 22 : 1;
var item = tables.TbItem.DataList.FirstOrDefault(x => x.SubType == subtype);
Check(item != null, "real item subtype " + subtype);
var data = new FishingData { fishItem = item, IsRetry = scenario == "retry" };
int cost = scenario == "retry" ? 5 : 10;
int energyRM = player.GetEnergyRM();
var expected = new Dictionary<string, string>(player.PlayerDataDic);
expected["energy"] = (initialEnergy - cost).ToString();
string expectedJson = JsonConvert.SerializeObject(expected);
var order = new List<string>();
int consumed = 0;
playerSubscription = GContext.OnEvent<PlayerData>().Subscribe(_ => order.Add("player"));
consumeSubscription = GContext.OnEvent<ConditionTypeEvent>().Subscribe(e =>
{
if (e.type == cfg.ConditionType.ConsumeEnergy)
{
Check(player.Energy == initialEnergy - cost, "event observes deducted energy");
order.Add("consume");
consumed += e.count;
store.Write("k", "event-" + e.count).RequireAccepted();
}
});
if (scenario == "cross-limit-boundary")
{
ObserveRecovery(() => player.ReduceEnergy(cost, false));
Check(player.Energy == 290 && player.GetEnergyRM() == energyRM + cost, "original crossing deducts once");
Check(order.SequenceEqual(new[] { "player", "player" }) && consumed == 0, "crossing publishes twice without inventing consume event");
var crossingData = new Dictionary<string, string>(player.PlayerDataDic);
Check(crossingData["newData"].Split(',')[3] == "290" && DateTime.Parse(crossingData["newData"].Split(',')[7]) > new DateTime(2026, 1, 1) && !crossingData.ContainsKey("energy"), "original crossing save has updated time and energy");
Check(fixture.Sets == 0 && fixture.Remote["NewPlayerDataDic"] == "original-save", "crossing uses ordinary save without immediate network");
var flushed = await KVMgr.SyncKVAsync(owner);
Check(flushed.TransportError == PlayerKVError.None && flushed.Blocked == 0 && flushed.Unknown == 0 && fixture.Sets == 1 && fixture.Auth == 1, "crossing ordinary save succeeds once");
Check(fixture.Remote["NewPlayerDataDic"] == JsonConvert.SerializeObject(crossingData), "crossing exact original serialized snapshot");
var reloaded = new PlayerData(tables, null);
Module02OriginalLoadTests.LoadCore(reloaded, JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]));
Check(reloaded.Energy == player.Energy && reloaded.equipRodID == player.equipRodID && (ulong)reloaded.gold == (ulong)player.gold, "crossing original parser retains energy rod and gold");
}
else
{
shell = new GameObject("KV original consumption fixed fixture");
shell.SetActive(false);
shell.hideFlags = HideFlags.HideAndDontSave;
var behavior = shell.AddComponent<FishingBehaviorB>();
typeof(FishingBehaviorB).GetField("_tables", fields).SetValue(behavior, tables);
typeof(FishingBehaviorB).GetField("fishingData", fields).SetValue(behavior, data);
typeof(FishingBehaviorB).GetField("saveStore", fields).SetValue(behavior, store);
Dictionary<string, string> subscriberWrites = null;
fixture.Unauthorized = scenario == "unauthorized";
fixture.HoldSet = scenario == "held-recovery";
invoked = true;
typeof(FishingBehaviorB).GetMethod("ConsumeBaitOnPlayfab", fields).Invoke(behavior, null);
Check(player.Energy == initialEnergy - cost && player.GetEnergyRM() == energyRM + cost, "one original local deduction");
Check(consumed == (subtype == 1 ? cost : 0), "original pack exclusion");
Check(order.SequenceEqual(subtype == 1 ? new[] { "player", "consume" } : new[] { "player" }), "publish before event");
if (subscribers != null)
{
Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single() == cost && subscribers.Lucky.DicTasks.Values.Single() == cost && subscribers.Magic.DicTasks.Values.Single() == cost && subscribers.Mini.Data.Exp == cost, "four actual subscribers process one original consume event");
Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy) == (ulong)cost, "actual achievement receives original event");
subscriberWrites = new[] { "BattlePassRecordTask", "MiniBPKeyNew", "EventLuckGameRecord", "EventLuckMagicPlayfabData" }.ToDictionary(key => key, key => store.Read(key).Value);
Check(subscriberWrites.Values.All(value => !string.IsNullOrEmpty(value)) && subscriberWrites.Keys.All(key => !fixture.Remote.ContainsKey(key)), "actual task payloads wait for ordinary save timing");
}
if (fixture.HoldSet)
{
Check(await Task.WhenAny(fixture.SetEntered.Task, Task.Delay(5000)) == fixture.SetEntered.Task, "held request entered");
Check(!completed.Task.IsCompleted && completionLogs == 0, "async void returned while actual request pending");
ObserveRecovery(() => player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed * 60));
Check(player.Energy == initialEnergy - cost + 1, "real recovery proceeds during request");
fixture.ReleaseSet.TrySetResult(true);
}
Check(await Task.WhenAny(completed.Task, Task.Delay(5000)) == completed.Task, "actual completion log observed");
Check(await completed.Task == !fixture.Unauthorized && completionLogs == 1, "original success/false result");
Check(saveFailureLogs == (fixture.Unauthorized ? 1 : 0), "expected 401 save error is observed exactly once");
Check(fixture.Sets == 1 && fixture.Auth == 1, "one immediate attempt and no reauthentication");
Check(fixture.Remote["k"] == "original", "ordinary event key not sent by immediate write");
if (fixture.Unauthorized)
{
Check(fixture.Remote["NewPlayerDataDic"] == "original-save", "401 did not commit snapshot");
}
else
{
Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "original exact pre-event snapshot plus energy");
Check(expected["newData"].Split(',')[3] == initialEnergy.ToString(), "copied newData retains original value");
if (subscribers != null)
{
var originalSnapshot = new PlayerData(tables, null);
Module02OriginalLoadTests.LoadCore(originalSnapshot, JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]));
Check(originalSnapshot.Energy == initialEnergy && originalSnapshot.equipRodID == player.equipRodID && (ulong)originalSnapshot.gold == (ulong)player.gold, "original parser reads pre-event snapshot values without canonical rewrite");
}
var flush = await KVMgr.SyncKVAsync(owner);
Check(flush.TransportError == PlayerKVError.None && flush.Blocked == 0 && flush.Unknown == 0 && fixture.Remote["k"] == (subtype == 1 ? "event-" + cost : "original"), "subsequent ordinary event write");
if (subscriberWrites != null)
{
Check(subscriberWrites.All(pair => fixture.Remote.TryGetValue(pair.Key, out var value) && value == pair.Value), "ordinary tick persists exact actual task payloads");
Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "task ordinary save does not rewrite original immediate snapshot");
}
if (fixture.HoldSet)
{
var recovered = JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]);
Check(recovered["newData"].Split(',')[3] == (initialEnergy - cost + 1).ToString() && !recovered.ContainsKey("energy"), "later recovery saved separately");
}
}
}
Check(!Enabled && !Ready && handle == null, "no temporary gameplay activation");
}
catch (Exception ex)
{
errors.Add(ex);
}
finally
{
fixture.ReleaseSet.TrySetResult(true);
if (invoked && await Task.WhenAny(completed.Task, Task.Delay(5000)) != completed.Task)
{
errors.Add(new InvalidOperationException("Original async void did not drain: " + scenario));
validationCleanupFailure = fixture;
}
Clean(() => player?.StopRecoveryEnergy());
Clean(() => consumeSubscription?.Dispose());
Clean(() => playerSubscription?.Dispose());
Clean(() => subscribers?.Dispose());
Clean(() => achievements?.Dispose());
Clean(() => store?.Dispose());
try
{
await fixture.Close();
}
catch (Exception ex)
{
errors.Add(ex);
validationCleanupFailure = fixture;
}
Clean(() =>
{
if (shell != null)
{
UnityEngine.Object.DestroyImmediate(shell);
}
});
Clean(() => container?.Dispose());
FishingContextAccess.Use(oldContainer);
FishingContextAccess.Events = oldEvents;
Application.logMessageReceived -= Log;
if (unexpectedLogs.Count != 0)
{
errors.Add(new InvalidOperationException(string.Join("\n", unexpectedLogs)));
}
}
if (errors.Count != 0)
{
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, "failure.json"), JsonConvert.SerializeObject(new { scenario, errors = errors.Select(x => x.ToString()), formalRequests = 0 }));
throw new AggregateException(errors);
}
passed.Add(scenario);
}
}
finally
{
FishingContextAccess.Use(oldContainer);
FishingContextAccess.Events = oldEvents;
LocalizationMgr.enumLanguage = oldLanguages;
try
{
textTable.SetValue(null, oldTextTable);
}
finally
{
Interlocked.Exchange(ref validationRunning, 0);
}
}
string result = JsonConvert.SerializeObject(new { success = true, groups = passed.Count, assertions = checks, passed, recoveryTiming = "manual-await-boundary", formalRequests = 0, synthetic = true, restored = true, folder });
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, "result.json"), result);
return result;
}
private sealed class OriginalFishingTestContext : SynchronizationContext
{
private readonly Queue<(SendOrPostCallback Callback, object State)> posts = new Queue<(SendOrPostCallback, object)>();
private int started;
private int completed;
internal int Count => posts.Count;
internal int Started => started;
internal int Completed => completed;
public override void Post(SendOrPostCallback callback, object state)
{
posts.Enqueue((callback, state));
}
public override void OperationStarted()
{
started++;
}
public override void OperationCompleted()
{
completed++;
}
internal (SendOrPostCallback Callback, object State) Peek()
{
return posts.Peek();
}
internal (SendOrPostCallback Callback, object State) Take()
{
return posts.Dequeue();
}
}
// Only a fixed idle-editor test; the handler inherited from EquipmentFixture never forwards requests. // Only a fixed idle-editor test; the handler inherited from EquipmentFixture never forwards requests.
internal static async Task<string> VerifyFishingForEditorAsync() internal static async Task<string> VerifyFishingForEditorAsync()
{ {
if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null)
Resources.FindObjectsOfTypeAll<GContext>().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || {
Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null)
throw new InvalidOperationException("Idle editor without a real permit required."); throw new InvalidOperationException("Idle editor without a real permit required.");
if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation running."); }
var saved=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction);
var oldContainer=GContext.container; var oldEvents=FishingContextAccess.Events; if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
var folder=Path.Combine(Root,"fishing-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); {
EquipmentFixture fixture=null; Task pending=null; Container container=null; PlayerData player=null; IDisposable subscription=null; throw new InvalidOperationException("Validation running.");
var oldLanguages=LocalizationMgr.enumLanguage; }
var textTable=typeof(LocalizationMgr).GetField("table",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
var oldTextTable=textTable.GetValue(null); var saved = (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction);
GameObject saveShell=null; var folder = Path.Combine(Root, "fishing-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
var pfField=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); EquipmentFixture fixture = null;
var oldPF=pfField.GetValue(null); Task pending = null;
FishingSubscribers subscribers=null; int checks = 0, groups = 0;
int checks=0,groups=0; var passed=new List<string>(); string result=null; var passed = new List<string>();
void Check(bool yes,string reason) { checks++; if(!yes) throw new InvalidOperationException("Fishing validation: "+reason); } string result = null;
void Check(bool yes, string reason)
{
checks++;
if (!yes)
{
throw new InvalidOperationException("Fishing validation: " + reason);
}
}
async Task Start(string name) async Task Start(string name)
{ {
subscribers?.Dispose(); subscribers=null; if (fixture != null)
player?.StopRecoveryEnergy(); player=null; subscription?.Dispose(); subscription=null; {
if(fixture!=null) await fixture.Close(); await fixture.Close();
if(pending!=null) { try { await pending; } catch { } pending=null; } }
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);saveShell=null;
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); container=null; if (pending != null)
fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString())); {
var owner=await fixture.Open(); try
Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null; fishingAction=null; {
Permit=new PlayerKVLoopPermit{Version=2,Operation="fishing-consume-once",PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}}; await pending;
RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; }
Attach(_=>fixture.Faults++,fixture.Close); Publish(owner); catch
{
}
pending = null;
}
fixture = new EquipmentFixture(Path.Combine(folder, (++groups).ToString()));
var owner = await fixture.Open();
Enabled = true;
Ready = false;
Error = null;
failed = 0;
handle = null;
collecting = null;
fishingAction = null;
Permit = new PlayerKVLoopPermit
{
Version = 2,
Operation = "fishing-consume-once",
PlayerId = "101",
AllowedKeys = new[]
{
"k",
"NewPlayerDataDic",
"late"
}
};
RunPath = folder;
mainThread = Thread.CurrentThread.ManagedThreadId;
mainContext = SynchronizationContext.Current;
Attach(_ => fixture.Faults++);
Publish(owner);
passed.Add(name); passed.Add(name);
} }
(FishingAction action,WriteBatch batch,PlayerKVEditResult primary) Batch(bool duplicates=false)
(FishingAction action, WriteBatch batch, PlayerKVEditResult primary) Batch(bool duplicates = false)
{ {
var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"first consume admitted"); var action = BeginFishing(new object ());
var batch=BeginFishingWrites(action); Check(ClaimFishingConsumption(action), "first consume admitted");
if(duplicates) { Write("NewPlayerDataDic","intermediate-local",false); Write("NewPlayerDataDic","intermediate-remote",true); } var batch = BeginFishingWrites(action);
Write("k","task",true); var primary=Write("NewPlayerDataDic","final",true); batch.Dispose(); if (duplicates)
return(action,batch,primary); {
Write("NewPlayerDataDic", "intermediate-local", false);
Write("NewPlayerDataDic", "intermediate-remote", true);
}
Write("k", "task", true);
var primary = Write("NewPlayerDataDic", "final", true);
batch.Dispose();
return (action, batch, primary);
} }
async Task Reject(Task task,string label)
async Task Reject(Task task, string label)
{ {
bool rejected=false; try { await task; } catch(InvalidOperationException) { rejected=true; } bool rejected = false;
Check(rejected && !Ready && fixture.Faults==1,label); try
{
await task;
}
catch (InvalidOperationException)
{
rejected = true;
}
Check(rejected && !Ready && fixture.Faults == 1, label);
} }
try try
{ {
Directory.CreateDirectory(folder); Directory.CreateDirectory(folder);
await Start("duplicate-state-writes-and-original-final-tickets"); await Start("duplicate-state-writes-and-original-final-tickets");
var b=Batch(true); Check(b.batch.Records.Count==4 && !b.batch.Records[0].Remote && b.batch.Records[0].Edit.Ticket==null,"local intermediate captured"); var b = Batch(true);
await ConfirmFishingAsync(b.action,b.batch,b.primary); Check(b.batch.Records.Count == 4 && !b.batch.Records[0].Remote && b.batch.Records[0].Edit.Ticket == null, "local intermediate captured");
Check(fixture.Remote["NewPlayerDataDic"]=="final" && fixture.Remote["k"]=="task","both final values persisted"); _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
int continued=0; ContinueFishing(b.action,()=>continued++); ContinueFishing(b.action,()=>continued++); await ConfirmFishingAsync(b.action, b.batch, b.primary);
Check(continued==1 && !ClaimFishingConsumption(b.action) && BeginFishing(new object())==null,"continue and consume/cast only once"); Check(fixture.Remote["NewPlayerDataDic"] == "final" && fixture.Remote["k"] == "task", "both final values persisted");
int continued = 0;
await Start("pending-response-does-not-continue"); b=Batch(); fixture.HoldSet=true; ContinueFishing(b.action, () => continued++);
pending=ConfirmFishingAsync(b.action,b.batch,b.primary); ContinueFishing(b.action, () => continued++);
Check(ReferenceEquals(await Task.WhenAny(fixture.SetEntered.Task,pending,Task.Delay(5000)),fixture.SetEntered.Task),"Set entered"); Check(continued == 1 && !ClaimFishingConsumption(b.action) && BeginFishing(new object ()) == null, "continue and consume/cast only once");
Check(!b.action.Confirmed && !b.action.Continued,"no continuation during pending response"); await Start("pending-response-does-not-continue");
fixture.ReleaseSet.TrySetResult(true); await pending; pending=null; b = Batch();
Check(b.action.Confirmed,"original response confirms"); fixture.HoldSet = true;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
await Start("final-localOnly-rejected-before-flush"); b=Batch(); pending = ConfirmFishingAsync(b.action, b.batch, b.primary);
Check(ReferenceEquals(await Task.WhenAny(fixture.SetEntered.Task, pending, Task.Delay(5000)), fixture.SetEntered.Task), "Set entered");
Check(!b.action.Confirmed && !b.action.Continued, "no continuation during pending response");
fixture.ReleaseSet.TrySetResult(true);
await pending;
pending = null;
Check(b.action.Confirmed, "original response confirms");
await Start("final-localOnly-rejected-before-flush");
b = Batch();
// A sealed record ledger from actual writes with a localOnly final key. // A sealed record ledger from actual writes with a localOnly final key.
collecting=b.batch; Write("k","local-tail",false); b.batch.Dispose(); collecting = b.batch;
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"local final cannot report remote success"); Check(fixture.Sets==0,"no flush of rejected batch"); Write("k", "local-tail", false);
b.batch.Dispose();
await Start("external-final-supersession"); b=Batch(); Write("NewPlayerDataDic","later-action",true); await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "local final cannot report remote success");
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"never substitute later ticket"); Check(fixture.Sets == 0, "no flush of rejected batch");
await Start("external-final-supersession");
await Start("lost-response-after-commit"); b=Batch(); fixture.LoseResponse=true; b = Batch();
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"Unknown stops run"); Write("NewPlayerDataDic", "later-action", true);
Check(fixture.Sets==1 && fixture.Remote["NewPlayerDataDic"]=="final","one uncertain commit, no replay"); _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "never substitute later ticket");
await Start("unauthorized-does-not-reauthenticate"); b=Batch(); fixture.Unauthorized=true; await Start("lost-response-after-commit");
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"401 stops run"); Check(fixture.Auth==1 && fixture.Sets==1,"single auth and Set"); b = Batch();
fixture.LoseResponse = true;
await Start("cancelled-object-does-not-continue-after-commit"); b=Batch(); fixture.HoldSet=true; _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
pending=ConfirmFishingAsync(b.action,b.batch,b.primary); await fixture.SetEntered.Task; await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "Unknown stops run");
CancelFishing(b.action,"synthetic disable"); fixture.ReleaseSet.TrySetResult(true); Check(fixture.Sets == 1 && fixture.Remote["NewPlayerDataDic"] == "final", "one uncertain commit, no replay");
await Reject(pending,"disabled original action cannot continue"); pending=null; await Start("unauthorized-does-not-reauthenticate");
Check(!b.action.Continued && b.action.Cancelled,"no stale continuation"); b = Batch();
fixture.Unauthorized = true;
await Start("close-before-confirmation"); b=Batch(); await fixture.Close(); _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"closed owner rejected"); Check(fixture.Sets==0,"close does not invent flush"); await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "401 stops run");
Check(fixture.Auth == 1 && fixture.Sets == 1, "single auth and Set");
await Start("partial-commit-is-not-atomic-success"); b=Batch(); fixture.PartialCommit=true; fixture.LoseResponse=true; await Start("cancelled-object-does-not-continue-after-commit");
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"partial unknown stops run"); b = Batch();
Check(fixture.Sets==1 && fixture.Remote["k"]=="task" && fixture.Remote["NewPlayerDataDic"]=="original-save","partial commit retained without compensation"); fixture.HoldSet = true;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
pending = ConfirmFishingAsync(b.action, b.batch, b.primary);
await fixture.SetEntered.Task;
CancelFishing(b.action, "synthetic disable");
fixture.ReleaseSet.TrySetResult(true);
await Reject(pending, "disabled original action cannot continue");
pending = null;
Check(!b.action.Continued && b.action.Cancelled, "no stale continuation");
await Start("close-before-confirmation");
b = Batch();
await fixture.Close();
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "closed owner rejected");
Check(fixture.Sets == 0, "close does not invent flush");
await Start("partial-commit-is-not-atomic-success");
b = Batch();
fixture.PartialCommit = true;
fixture.LoseResponse = true;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "partial unknown stops run");
Check(fixture.Sets == 1 && fixture.Remote["k"] == "task" && fixture.Remote["NewPlayerDataDic"] == "original-save", "partial commit retained without compensation");
await Start("storage-error-before-send"); await Start("storage-error-before-send");
var stateFile=Directory.GetFiles(Path.Combine(folder,groups.ToString()),"state.json",SearchOption.AllDirectories).Single(); var stateFile = Directory.GetFiles(Path.Combine(folder, groups.ToString()), "state.json", SearchOption.AllDirectories).Single();
using(var locked=new FileStream(stateFile,FileMode.Open,FileAccess.Read,FileShare.None)) using (var locked = new FileStream(stateFile, FileMode.Open, FileAccess.Read, FileShare.None))
{ {
bool storage=false; try { Write("NewPlayerDataDic","locked",true); } catch(InvalidOperationException e) { storage=e.Message.Contains("StorageError"); } bool storage = false;
Check(storage && !Ready && fixture.Faults==1 && fixture.Sets==0,"storage failure cannot be accepted or retried");
}
foreach(bool complete in new[]{false,true})
{
await Start(complete?"actual-tween-autoKill-after-complete":"actual-tween-kill-before-complete");
var shell=new GameObject("KV inactive fishing tween fixture"); shell.SetActive(false); shell.hideFlags=HideFlags.HideAndDontSave;
Tween tween=null;
// DOTween.Init deliberately does nothing in EditMode; Kill otherwise returns
// before reaching the real tween engine. Scope this fixed fixture gate to this
// synchronous block and restore it even when an assertion fails.
var initialized=typeof(DOTween).GetField("initialized",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Public);
var wasInitialized=initialized.GetValue(null);
try try
{ {
initialized.SetValue(null,true); Write("NewPlayerDataDic", "locked", true);
var behavior=shell.AddComponent<FishingBehaviorB>();
var action=BeginFishing(behavior);
typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action);
float value=0; tween=DOTween.To(()=>value,v=>value=v,1,100).SetAutoKill(true).SetUpdate(UpdateType.Manual);
typeof(FishingBehaviorB).GetMethod("TrackKVCastTween",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,new object[]{tween});
Check(tween.onKill!=null && tween.onComplete!=null,"actual callbacks installed");
if(complete) tween.Complete(); else tween.Kill(false);
Check(action.Cancelled!=complete && !action.Continued,"actual Tween Kill boundary complete="+complete+" cancelled="+action.Cancelled+" enabled="+Enabled+" active="+tween.IsActive());
Check(fixture.Faults==(complete?0:1) && fixture.Sets==0,"autoKill is not a cancellation");
} }
finally catch (InvalidOperationException e)
{ {
try { tween?.Kill(false); UnityEngine.Object.DestroyImmediate(shell); } storage = e.Message.Contains("StorageError");
finally { initialized.SetValue(null,wasInitialized); }
} }
Check(storage && !Ready && fixture.Faults == 1 && fixture.Sets == 0, "storage failure cannot be accepted or retried");
} }
await Start("completed-action-normal-disable-is-not-failure"); await Start("observation-timeout-does-not-replay");
var completedShell=new GameObject("KV completed disable fixture");completedShell.SetActive(false);completedShell.hideFlags=HideFlags.HideAndDontSave; b = Batch();
fixture.HoldSet = true;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
pending = ConfirmFishingAsync(b.action, b.batch, b.primary);
await Reject(pending, "30 second observer deadline stops continuation");
pending = null;
Check(fixture.Sets == 1 && !b.action.Continued, "timeout leaves one admitted write");
fixture.ReleaseSet.TrySetResult(true);
await Start("equipment-permit-cannot-cast");
Permit.Version = 1;
Permit.Operation = null;
bool denied = false;
try try
{ {
var behavior=completedShell.AddComponent<FishingBehaviorB>(); BeginFishing(new object ());
var action=BeginFishing(behavior);Check(ClaimFishingConsumption(action),"completed action admitted");
var batch=BeginFishingWrites(action);var primary=Write("NewPlayerDataDic","completed",true);batch.Dispose();
await ConfirmFishingAsync(action,batch,primary);ContinueFishing(action,()=>{});
typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action);
typeof(FishingBehaviorB).GetMethod("OnDisable",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,null);
Check(action.Cancelled && action.Continued && Ready && fixture.Faults==0,"normal disable after completed consumption must not Fault");
Check(!FishingCurrent(action) && BeginFishing(behavior)==null,"normal disable still revokes callbacks and never restores quota");
} }
finally { UnityEngine.Object.DestroyImmediate(completedShell); } catch (InvalidOperationException)
await Start("observation-timeout-does-not-replay"); b=Batch(); fixture.HoldSet=true;
pending=ConfirmFishingAsync(b.action,b.batch,b.primary);
await Reject(pending,"30 second observer deadline stops continuation"); pending=null;
Check(fixture.Sets==1 && !b.action.Continued,"timeout leaves one admitted write");
fixture.ReleaseSet.TrySetResult(true);
await Start("equipment-permit-cannot-cast"); Permit.Version=1; Permit.Operation=null;
bool denied=false; try { BeginFishing(new object()); } catch(InvalidOperationException) { denied=true; }
Check(denied && fixture.Sets==0,"old permit cannot consume");
foreach(var scenario in new[]{"above-limit","cross-limit","below-limit","recovery-during-pending"})
{ {
await Start("actual-PlayerData-"+scenario); denied = true;
int initialEnergy=scenario=="above-limit"?400:scenario=="cross-limit"?300:250;
var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json")))));
container=new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events=new EventAggregator();
container.RegisterInstance(tables);
saveShell=new GameObject("KV inactive consumption storage fixture");saveShell.SetActive(false);saveShell.hideFlags=HideFlags.HideAndDontSave;
var pf=saveShell.AddComponent<PlayFabMgr>();Check(pf.IsUnstartedForKVLoad,"consumption fixture never starts PF lifecycle");pfField.SetValue(null,pf);
var store=new PlayFabPlayerSaveStore(pf,CancellationToken.None);
container.RegisterInstance<IPlayerSaveStore>(store);
LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList();
textTable.SetValue(null,null);
player=new PlayerData(tables,null); var small=new SmallGameData(tables,null,null); var fish=new PlayerFishData(tables,null);
var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var events=new FishingEventData(tables);
container.RegisterInstance(player); container.RegisterInstance(small); container.RegisterInstance(fish);
container.RegisterInstance(achievements); container.RegisterInstance(box); container.RegisterInstance(events);
player.playerSaveStore=store;fish.playerSaveStore=store;small.playerSaveStore=store;events.playerSaveStore=store;
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
fish.ResetForKVLoad(); small.ResetForKVLoad(); achievements.ResetForKVLoad(); box.ResetForKVLoad();
achievements.Init();
int rod=tables.TbRodData.DataMap.Keys.First(),map=tables.TbMapData.DataMap.Keys.First();
var core=new PlayerKVCoreState(new object[]{1,10UL,0,initialEnergy,5,0,rod,new DateTime(2026,1,1),0,7,10UL,2,map,map,0,0,0,0,0});
player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary<string,string>(),new Dictionary<int,int>{{-1,1}},new List<int>(),0,false,"0"));
// Fixed backing property only, no caller-controlled reflection or gameplay registration.
typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player,300);
typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player,1);
if(scenario=="above-limit")
{
Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}).ToArray();
subscribers=new FishingSubscribers();
subscribers.Bind(tables,events,container);
Check(!tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==cfg.ConditionType.ConsumeEnergy)) &&
!tables.TbTriggerPackManager.DataList.Any(t=>t.Condition==cfg.ConditionType.ConsumeEnergy),"real config excludes async activation and trigger-pack writes");
} }
int consumed=0,notified=0;
subscription=GContext.OnEvent<ConditionTypeEvent>().Subscribe(e=>{if(e.type==cfg.ConditionType.ConsumeEnergy){consumed+=e.count; Write("k","task-"+e.count,true);}}); Check(denied && fixture.Sets == 0, "old permit cannot consume");
using(GContext.OnEvent<PlayerData>().Subscribe(_=>notified++)) result = JsonConvert.SerializeObject(new { success = true, groups, assertions = checks, passed, formalRequests = 0, synthetic = true, restored = true, folder });
{
var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"real action claimed");
fixture.HoldSet=scenario=="recovery-during-pending";
var consumption=player.ConsumeEnergyForKVLoopAsync(3,action); pending=consumption;
if(fixture.HoldSet)
{
await fixture.SetEntered.Task;
Check(!action.Confirmed && !action.Continued,"real player pending cannot continue");
// Drive the actual due recovery tick while the original Set is still held.
// No replacement serialization, no paused production recovery algorithm.
player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed*60);
Check((int)player.Energy==initialEnergy-2 && !action.Confirmed,"real recovery can write during pending");
fixture.ReleaseSet.TrySetResult(true);
}
await consumption; pending=null;
Check((int)player.Energy==initialEnergy-(fixture.HoldSet?2:3) && player.GetEnergyRM()==10 && consumed==3,"one real deduction and event");
Check(notified==(scenario=="cross-limit"?2:fixture.HoldSet?3:1),"original notification count preserved");
var modern=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
Check(modern["newData"].Split(',')[3]==(initialEnergy-3).ToString() && !modern.ContainsKey("energy"),"one canonical representation");
Check(fixture.Remote["k"]=="task-3" && action.Confirmed,"synchronous task write confirmed");
Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy)==3 && modern["ad"].Contains(":") && modern["ad"]==achievements.AchievementDataToJson(),"actual achievement subscriber captured after event");
var savedTime=DateTime.Parse(modern["newData"].Split(',')[7]);
Check(scenario=="cross-limit"?savedTime>new DateTime(2026,1,1):savedTime==new DateTime(2026,1,1),"recovery timestamp crossing behavior preserved");
if(fixture.HoldSet)
{
await KVMgr.FlushKVAsync(action.Owner);
var recovered=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
Check(recovered["newData"].Split(',')[3]==(initialEnergy-2).ToString(),"later recovery has separate flush, not original action confirmation");
}
if(subscribers!=null)
{
Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single()==3 && subscribers.Lucky.DicTasks.Values.Single()==3 && subscribers.Magic.DicTasks.Values.Single()==3,"actual task subscriptions each receive one consume event");
Check(subscribers.Mini.Data.Exp==3,"actual mini BP subscription receives the event");
Check(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}.All(fixture.Remote.ContainsKey),"all actual subscriber save methods confirmed in original action");
}
var parsed=new PlayerKVSchemaSnapshot("fixed-fishing",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
new Dictionary<string,int>(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24,0,0,0,DateTimeKind.Utc),0);
var errors=new PlayerKVSchemaDiagnostics();
Check(PlayerKVSaveSchema.DecodeCore(new Dictionary<string,string>(),modern,parsed,errors)!=null && errors.TotalErrors==0,"actual serialized core loads without alias conflict");
}
}
result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,restored=true,folder});
} }
finally finally
{ {
try try
{ {
subscribers?.Dispose(); subscribers=null; if (fixture != null)
player?.StopRecoveryEnergy(); subscription?.Dispose(); {
if(fixture!=null) await fixture.Close(); await fixture.Close();
if(pending!=null) { try { await pending; } catch(Exception e) { Debug.LogWarning("Observed fishing test failure: "+e.GetType().Name); } } }
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); if (pending != null)
LocalizationMgr.enumLanguage=oldLanguages; textTable.SetValue(null,oldTextTable); {
(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction)=saved; try
{
await pending;
}
catch (Exception e)
{
Debug.LogWarning("Observed fishing test failure: " + e.GetType().Name);
}
}
(Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction) = saved;
}
catch
{
validationCleanupFailure = fixture;
throw;
}
finally
{
Interlocked.Exchange(ref validationRunning, 0);
} }
catch { validationCleanupFailure=fixture; throw; }
finally { Interlocked.Exchange(ref validationRunning,0); }
} }
File.WriteAllText(Path.Combine(folder,"result.json"),result); return result;
File.WriteAllText(Path.Combine(folder, "result.json"), result);
return result;
} }
// Fixed real subscriber fixture. Reflection targets are compiled constants, never supplied by CLI. // Fixed real subscriber fixture. Reflection targets are compiled constants, never supplied by CLI.
sealed class FishingSubscribers:IDisposable sealed class FishingSubscribers:IDisposable
{ {
const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic; const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic;
static readonly System.Reflection.FieldInfo PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null); private readonly object oldRed = Red.GetValue(null);
GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider; private LuckyTaskData magicTask;
private MiniBattlePassDataProvider provider;
internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky; internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky;
internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini; internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini;
internal void Bind(cfg.Tables tables,FishingEventData events,Container container) internal void Bind(cfg.Tables tables,FishingEventData events,Container container)
{ {
Red.SetValue(null,new RedPointManager()); Red.SetValue(null,new RedPointManager());
shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave;
var pf=shell.AddComponent<PlayFabMgr>();
if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture.");
PF.SetValue(null,pf);
var store=container.Resolve<IPlayerSaveStore>(); var store=container.Resolve<IPlayerSaveStore>();
container.RegisterInstance<IPlayerSaveStore>(store); container.RegisterInstance<IPlayerSaveStore>(store);
container.Resolve<PlayerData>().playerSaveStore=store; container.Resolve<PlayerData>().playerSaveStore=store;
@@ -322,10 +726,40 @@ namespace game.PlayerDataStorage
} }
public void Dispose() public void Dispose()
{ {
Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose(); var errors = new List<Exception>();
if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose(); void Release(Action action)
PF.SetValue(null,oldPF);Red.SetValue(null,oldRed); {
if(shell!=null)UnityEngine.Object.DestroyImmediate(shell); try
{
action();
}
catch (Exception error)
{
errors.Add(error);
}
}
Release(() => Battle?.Dispose());
Release(() => Lucky?.Dispose());
Release(() => magicTask?.Dispose());
Release(() =>
{
if (Mini != null)
{
(typeof(MiniBattlePassDataModel).GetField("disposables", Fields).GetValue(Mini) as IDisposable)?.Dispose();
}
});
Release(() => Red.SetValue(null, oldRed));
Release(() =>
{
if (!ReferenceEquals(Red.GetValue(null), oldRed))
{
throw new InvalidOperationException("Subscriber fixture statics did not restore.");
}
});
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
} }
} }
sealed class FishingContextAccess:GContext sealed class FishingContextAccess:GContext

View File

@@ -86,37 +86,17 @@ namespace game.PlayerDataStorage
internal static PlayerKVLoopPermit Permit { get; private set; } internal static PlayerKVLoopPermit Permit { get; private set; }
internal static string Root => Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests")); internal static string Root => Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests"));
internal static string RunPath { get; private set; } internal static string RunPath { get; private set; }
internal static string CacheRoot => Path.Combine(RunPath,"cache");
static PlayerKVGameSessionHandle handle; static PlayerKVGameSessionHandle handle;
static Action<string> onFault; static Action<string> onFault;
static Func<Task> onClose;
static WriteBatch collecting; static WriteBatch collecting;
static int mainThread, failed; static int mainThread, failed;
static SynchronizationContext mainContext; static SynchronizationContext mainContext;
static readonly object auditGate=new object(); static readonly object auditGate=new object();
internal static void BeforeBootstrap() internal static void Attach(Action<string> fault)
{ {
var path=Path.Combine(Root,"permit.json"); onFault = fault;
if(!File.Exists(path)) return;
// A malformed or active permit must never silently fall back to PF.
Enabled=true;
if(JObject.Parse(File.ReadAllText(path))["Enabled"]?.Value<bool>()==false) { Enabled=false; return; }
if(PlayFabMgr.HasLiveInstanceForKVLoad) throw new InvalidOperationException("Cannot switch a running storage backend.");
Permit=PlayerKVLoopPermit.Read(path);
using(var process=System.Diagnostics.Process.GetCurrentProcess())
{
var identity=process.Id+"_"+process.StartTime.ToUniversalTime().Ticks;
PlayerKVLoopPermit.Claim(Root,identity);
RunPath=Path.Combine(Root,"runs",identity); Directory.CreateDirectory(RunPath);
}
mainThread=Thread.CurrentThread.ManagedThreadId;
mainContext=SynchronizationContext.Current ?? throw new InvalidOperationException("Unity main context required.");
GContext.container.Resolve<IConfig>().Set("SaveBackend","kv-loop");
GContext.container.Resolve<IConfig>().Set("CustomID",Permit.PlayerId);
Audit("claim",new{Permit.PlayerId,Permit.BackupSha256});
} }
internal static void Attach(Action<string> fault,Func<Task> close) { onFault=fault; onClose=close; }
internal static void Publish(PlayerKVGameSessionHandle value) internal static void Publish(PlayerKVGameSessionHandle value)
{ {
if(!Enabled || failed!=0 || handle!=null || value==null || value.PlayerId!=Permit.PlayerId) if(!Enabled || failed!=0 || handle!=null || value==null || value.PlayerId!=Permit.PlayerId)
@@ -160,12 +140,6 @@ namespace game.PlayerDataStorage
internal WriteBatch(PlayerKVGameSessionHandle owner) { Owner=owner; } internal WriteBatch(PlayerKVGameSessionHandle owner) { Owner=owner; }
public void Dispose() { if(ReferenceEquals(collecting,this)) collecting=null; } public void Dispose() { if(ReferenceEquals(collecting,this)) collecting=null; }
} }
internal static WriteBatch BeginEquip()
{
if(Permit?.AllowsFishing==true) throw Stop("Equipment is outside this fishing permit.");
if(collecting!=null) throw Stop("Reentrant equipment action.");
return collecting=new WriteBatch(Capture());
}
internal sealed class WriteRecord internal sealed class WriteRecord
{ {
internal readonly string Key,ValueHash; internal readonly string Key,ValueHash;
@@ -220,9 +194,6 @@ namespace game.PlayerDataStorage
last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Fishing final writes require original remote tickets."); last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Fishing final writes require original remote tickets.");
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30))) using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
{ {
var flush=await KVMgr.FlushKVAsync(batch.Owner,timeout.Token);
if(!FishingCurrent(action) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None)
throw Stop("Fishing flush: "+flush.TransportError);
foreach(var record in batch.Records) foreach(var record in batch.Records)
{ {
var ticket=record.Edit.Ticket; var ticket=record.Edit.Ticket;
@@ -250,33 +221,6 @@ namespace game.PlayerDataStorage
continuation(); continuation();
Audit("fishing-consume-ready",new{action.Owner.OwnerInstanceId,action.Owner.Epoch}); Audit("fishing-consume-ready",new{action.Owner.OwnerInstanceId,action.Owner.Epoch});
} }
internal static async Task ConfirmEquipAsync(WriteBatch batch,PlayerKVEditResult primary)
{
if(primary?.Ticket==null || !batch.Edits.Contains(primary) || !ReferenceEquals(Capture(),batch.Owner))
throw Stop("Missing original equipment ticket.");
var flush=await KVMgr.FlushKVAsync(batch.Owner);
if(flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) throw Stop("Equipment flush: "+flush.TransportError);
foreach(var edit in batch.Edits)
{
if(edit.Ticket==null) continue; // localOnly is audited, never claimed as remotely confirmed.
var result=await KVMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket);
if(result.Status!=PlayerKVTicketStatus.Confirmed) throw Stop("Equipment ticket: "+result.Status);
}
if(!ReferenceEquals(Capture(),batch.Owner)) throw Stop("Equipment session changed.");
Audit("equipment-confirmed",new{tickets=batch.Edits.Count(e=>e.Ticket!=null)});
}
internal static async Task FlushObservedAsync()
{
if(!Ready) return;
try
{
var result=await KVMgr.FlushKVAsync(Capture());
if(result.Unknown>0 || result.Blocked>0 || result.TransportError!=PlayerKVError.None) throw Stop("Periodic flush: "+result.TransportError);
}
catch(Exception e) { Fault(e.Message); }
}
internal static void Revoke() { Ready=false; }
internal static Task CloseAsync() { Revoke(); return onClose?.Invoke() ?? Task.CompletedTask; }
internal static InvalidOperationException Stop(string message) { Fault(message); return new InvalidOperationException(message); } internal static InvalidOperationException Stop(string message) { Fault(message); return new InvalidOperationException(message); }
internal static void Fault(string message) internal static void Fault(string message)
{ {
@@ -299,127 +243,281 @@ namespace game.PlayerDataStorage
static object validationCleanupFailure; static object validationCleanupFailure;
internal static async Task<string> VerifyEquipmentForEditorAsync() internal static async Task<string> VerifyEquipmentForEditorAsync()
{ {
if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null)
UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || {
Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null)
throw new InvalidOperationException("An idle editor without a real loop permit is required."); throw new InvalidOperationException("An idle editor without a real loop permit is required.");
if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation already running."); }
var original=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext);
var folder=Path.Combine(Root,"fixed-equipment-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
EquipmentFixture fixture=null; {
Task pendingConfirmation=null; throw new InvalidOperationException("Validation already running.");
string result=null; }
int assertions=0, groups=0;
void Check(bool ok,string reason) { assertions++; if(!ok) throw new InvalidOperationException("Equipment validation: "+reason); } var original = (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext);
var folder = Path.Combine(Root, "fixed-equipment-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
EquipmentFixture fixture = null;
Task pendingConfirmation = null;
string result = null;
int assertions = 0, groups = 0;
WriteBatch BeginEquip()
{
if (Permit?.AllowsFishing == true)
{
throw Stop("Equipment is outside this fishing permit.");
}
if (collecting != null)
{
throw Stop("Reentrant equipment action.");
}
return collecting = new WriteBatch(Capture());
}
async Task ConfirmEquipAsync(WriteBatch batch, PlayerKVEditResult primary)
{
if (primary?.Ticket == null || !batch.Edits.Contains(primary) || !ReferenceEquals(Capture(), batch.Owner))
{
throw Stop("Missing original equipment ticket.");
}
foreach (var edit in batch.Edits)
{
// localOnly is audited, never claimed as remotely confirmed.
if (edit.Ticket == null)
{
continue;
}
var result = await KVMgr.WaitForKVTicketAsync(batch.Owner, edit.Ticket);
if (result.Status != PlayerKVTicketStatus.Confirmed)
{
throw Stop("Equipment ticket: " + result.Status);
}
}
if (!ReferenceEquals(Capture(), batch.Owner))
{
throw Stop("Equipment session changed.");
}
Audit("equipment-confirmed", new { tickets = batch.Edits.Count(e => e.Ticket != null) });
}
void Check(bool ok, string reason)
{
assertions++;
if (!ok)
{
throw new InvalidOperationException("Equipment validation: " + reason);
}
}
async Task Start() async Task Start()
{ {
if(fixture!=null) await fixture.Close(); if (fixture != null)
fixture=new EquipmentFixture(Path.Combine(folder,groups.ToString())); {
var owner=await fixture.Open(); await fixture.Close();
Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null; }
Permit=new PlayerKVLoopPermit{PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}};
RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; fixture = new EquipmentFixture(Path.Combine(folder, groups.ToString()));
Attach(_=>fixture.Faults++,fixture.Close); Publish(owner); var owner = await fixture.Open();
Enabled = true;
Ready = false;
Error = null;
failed = 0;
handle = null;
collecting = null;
Permit = new PlayerKVLoopPermit
{
PlayerId = "101",
AllowedKeys = new[]
{
"k",
"NewPlayerDataDic",
"late"
}
};
RunPath = folder;
mainThread = Thread.CurrentThread.ManagedThreadId;
mainContext = SynchronizationContext.Current;
Attach(_ => fixture.Faults++);
Publish(owner);
groups++; groups++;
} }
async Task Reject(Task task,string contains)
async Task Reject(Task task, string contains)
{ {
bool rejected=false; bool rejected = false;
try { await task; } catch(InvalidOperationException e) { rejected=e.Message.Contains(contains); }
Check(rejected,"expected rejection: "+contains);
Check(!Ready && fixture.Faults==1,"one fault revokes facade");
}
try
{
if(!PlayerKVGameLoadGuard.TryEnter(out var probe)) throw new InvalidOperationException("Load guard occupied.");
probe.Dispose(); Directory.CreateDirectory(folder);
await Start();
Check(Read("missing")==null,"valid missing key remains null");
var batch=BeginEquip();
// This nested callback represents the synchronous Publish subscriber in the equipment chain.
Action nested=()=>Write("k","nested",true);
nested(); var primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose();
Check(batch.Edits.Count==2 && ReferenceEquals(primary,batch.Edits[1]),"all original edits collected");
Check(fixture.Sets==0,"write acceptance is local only");
await ConfirmEquipAsync(batch,primary);
foreach(var edit in batch.Edits) Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"each original ticket confirmed");
Check(fixture.Remote["k"]=="nested" && fixture.Remote["NewPlayerDataDic"]=="whole-save" && Ready && fixture.Faults==0,"complete batch committed");
await Start(); batch=BeginEquip();
var first=Write("k","first",true); Write("k","second",true);
primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose();
await Reject(ConfirmEquipAsync(batch,primary),"Superseded");
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"original nested ticket never replaced");
await Start(); batch=BeginEquip();
Write("k","nested",true); primary=Write("NewPlayerDataDic","watermark",true); batch.Dispose();
fixture.HoldSet=true;
var confirmation=ConfirmEquipAsync(batch,primary);
pendingConfirmation=confirmation;
var entered=await Task.WhenAny(fixture.SetEntered.Task,confirmation,Task.Delay(5000));
Check(ReferenceEquals(entered,fixture.SetEntered.Task),"first set entered");
var late=Write("late","later-write",true);
Check(batch.Edits.Count==2,"later write excluded from synchronous batch");
fixture.ReleaseSet.TrySetResult(true); await confirmation; pendingConfirmation=null;
Check(!fixture.Remote.ContainsKey("late"),"flush watermark excludes later write");
await KVMgr.FlushKVAsync(batch.Owner);
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,late.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"later write needs its own flush");
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","unauthorized",true); batch.Dispose();
fixture.Unauthorized=true;
await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush");
Check(fixture.Auth==1 && fixture.Sets==1,"401 never reauthenticates or retries");
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","lost-response",true); batch.Dispose();
fixture.LoseResponse=true;
await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush");
Check(fixture.Remote["NewPlayerDataDic"]=="lost-response" && fixture.Sets==1,"unknown remote commit is not replayed");
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,primary.Ticket)).Status==PlayerKVTicketStatus.Unknown,"unknown original ticket preserved");
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","closed",true); batch.Dispose();
await fixture.Close();
await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush");
Check(fixture.Sets==0,"close does not flush pending writes");
await Start(); bool denied=false;
try { Write("unbacked-key","x",true); } catch(InvalidOperationException e) { denied=e.Message.Contains("permission"); }
Check(denied && fixture.Sets==0 && !Ready,"key permission checked before writes");
await Start(); await fixture.Close(); denied=false;
try { Read("k"); } catch(InvalidOperationException) { denied=true; }
Check(denied && !Ready,"stale read cannot become missing");
var originalContainer=GContext.container;
var container=new Container();
try try
{ {
var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData")); await task;
var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json")))); }
catch (InvalidOperationException e)
{
rejected = e.Message.Contains(contains);
}
Check(rejected, "expected rejection: " + contains);
Check(!Ready && fixture.Faults == 1, "one fault revokes facade");
}
try
{
if (!PlayerKVGameLoadGuard.TryEnter(out var probe))
{
throw new InvalidOperationException("Load guard occupied.");
}
probe.Dispose();
Directory.CreateDirectory(folder);
await Start();
Check(Read("missing") == null, "valid missing key remains null");
var batch = BeginEquip();
// This nested callback represents the synchronous Publish subscriber in the equipment chain.
Action nested = () => Write("k", "nested", true);
nested();
var primary = Write("NewPlayerDataDic", "whole-save", true);
batch.Dispose();
Check(batch.Edits.Count == 2 && ReferenceEquals(primary, batch.Edits[1]), "all original edits collected");
Check(fixture.Sets == 0, "write acceptance is local only");
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await ConfirmEquipAsync(batch, primary);
foreach (var edit in batch.Edits)
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed, "each original ticket confirmed");
Check(fixture.Remote["k"] == "nested" && fixture.Remote["NewPlayerDataDic"] == "whole-save" && Ready && fixture.Faults == 0, "complete batch committed");
await Start();
batch = BeginEquip();
var first = Write("k", "first", true);
Write("k", "second", true);
primary = Write("NewPlayerDataDic", "whole-save", true);
batch.Dispose();
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await Reject(ConfirmEquipAsync(batch, primary), "Superseded");
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, first.Ticket)).Status == PlayerKVTicketStatus.Superseded, "original nested ticket never replaced");
await Start();
batch = BeginEquip();
Write("k", "nested", true);
primary = Write("NewPlayerDataDic", "watermark", true);
batch.Dispose();
fixture.HoldSet = true;
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
var confirmation = ConfirmEquipAsync(batch, primary);
pendingConfirmation = confirmation;
var entered = await Task.WhenAny(fixture.SetEntered.Task, confirmation, Task.Delay(5000));
Check(ReferenceEquals(entered, fixture.SetEntered.Task), "first set entered");
var late = Write("late", "later-write", true);
Check(batch.Edits.Count == 2, "later write excluded from synchronous batch");
fixture.ReleaseSet.TrySetResult(true);
await confirmation;
pendingConfirmation = null;
Check(!fixture.Remote.ContainsKey("late"), "flush watermark excludes later write");
await KVMgr.SyncKVAsync(batch.Owner);
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, late.Ticket)).Status == PlayerKVTicketStatus.Confirmed, "later write needs its own flush");
await Start();
batch = BeginEquip();
primary = Write("NewPlayerDataDic", "unauthorized", true);
batch.Dispose();
fixture.Unauthorized = true;
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
Check(fixture.Auth == 1 && fixture.Sets == 1, "401 never reauthenticates or retries");
await Start();
batch = BeginEquip();
primary = Write("NewPlayerDataDic", "lost-response", true);
batch.Dispose();
fixture.LoseResponse = true;
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
Check(fixture.Remote["NewPlayerDataDic"] == "lost-response" && fixture.Sets == 1, "unknown remote commit is not replayed");
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, primary.Ticket)).Status == PlayerKVTicketStatus.Unknown, "unknown original ticket preserved");
await Start();
batch = BeginEquip();
primary = Write("NewPlayerDataDic", "closed", true);
batch.Dispose();
await fixture.Close();
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
Check(fixture.Sets == 0, "close does not flush pending writes");
await Start();
bool denied = false;
try
{
Write("unbacked-key", "x", true);
}
catch (InvalidOperationException e)
{
denied = e.Message.Contains("permission");
}
Check(denied && fixture.Sets == 0 && !Ready, "key permission checked before writes");
await Start();
await fixture.Close();
denied = false;
try
{
Read("k");
}
catch (InvalidOperationException)
{
denied = true;
}
Check(denied && !Ready, "stale read cannot become missing");
var originalContainer = GContext.container;
var container = new Container();
try
{
var configPath = Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData"));
var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath, name + ".json"))));
container.RegisterInstance<cfg.Tables>(tables); container.RegisterInstance<cfg.Tables>(tables);
EquipmentContextAccess.Use(container); EquipmentContextAccess.Use(container);
using(container.InjectionScope()) container.Register<EventShootingRangeData>().AsSingleton(); using (container.InjectionScope())
var shooting=container.Resolve<EventShootingRangeData>(); container.Register<EventShootingRangeData>().AsSingleton();
Check(shooting!=null && ReferenceEquals(shooting,container.Resolve<EventShootingRangeData>()),"M24 container resolves one instance"); var shooting = container.Resolve<EventShootingRangeData>();
shooting.ResetForKVLoad(); Check(shooting != null && ReferenceEquals(shooting, container.Resolve<EventShootingRangeData>()), "M24 container resolves one instance");
Check(!shooting.KVLoadPresent,"M24 can enter the existing load transaction without activation"); shooting.LoadData("{\"EventId\":11,\"AmmoCount\":7}");
var shootingData = JObject.FromObject(Module02OriginalLoadTests.Field<object>(shooting, "_data"));
Check((int)shootingData["EventId"] == 11 && (int)shootingData["AmmoCount"] == 7, "M24 registered instance uses original load method");
} }
finally { EquipmentContextAccess.Use(originalContainer); container.Dispose(); } finally
Check(ReferenceEquals(GContext.container,originalContainer),"M24 global container restored"); {
EquipmentContextAccess.Use(originalContainer);
container.Dispose();
}
Check(ReferenceEquals(GContext.container, originalContainer), "M24 global container restored");
groups++; groups++;
result=JsonConvert.SerializeObject(new{success=true,groups,assertions,formalRequests=0,synthetic=true,restored=true,folder}); result = JsonConvert.SerializeObject(new { success = true, groups, assertions, formalRequests = 0, synthetic = true, restored = true, folder });
} }
finally finally
{ {
try try
{ {
if(fixture!=null) await fixture.Close(); if (fixture != null)
if(pendingConfirmation!=null) {
try { await pendingConfirmation; } catch(Exception e) { Debug.LogWarning("Observed failed synthetic confirmation during cleanup: "+e.GetType().Name); } await fixture.Close();
(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext)=original; }
if (pendingConfirmation != null)
{
try
{
await pendingConfirmation;
}
catch (Exception e)
{
Debug.LogWarning("Observed failed synthetic confirmation during cleanup: " + e.GetType().Name);
}
}
(Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext) = original;
}
catch
{
validationCleanupFailure = fixture;
throw;
}
finally
{
Interlocked.Exchange(ref validationRunning, 0);
} }
catch { validationCleanupFailure=fixture; throw; }
finally { Interlocked.Exchange(ref validationRunning,0); }
} }
File.WriteAllText(Path.Combine(folder,"result.json"),result);
File.WriteAllText(Path.Combine(folder, "result.json"), result);
return result; return result;
} }
@@ -430,37 +528,29 @@ namespace game.PlayerDataStorage
} }
internal static string InspectPFBackupForEditor(string path) internal static string InspectPFBackupForEditor(string path)
{ {
if(Enabled || validationRunning!=0 || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || if (Enabled || validationRunning != 0 || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0)
UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length!=0)
throw new InvalidOperationException("Idle editor required for source inspection.");
path=Path.GetFullPath(path);
var allowed=Path.GetFullPath(Path.Combine(Root,"pf-source-backups"))+Path.DirectorySeparatorChar;
if(!path.StartsWith(allowed,StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Expected local PF source backup.");
var bytes=File.ReadAllBytes(path); var source=JObject.Parse(Encoding.UTF8.GetString(bytes));
var id=(string)source["customId"];
if((string)source["source"]!="PlayFab" || (string)source["titleId"]!="108EE8" || (id!="101" && id!="102"))
throw new InvalidOperationException("Source identity mismatch.");
var raw=source["data"].ToObject<Dictionary<string,string>>();
var originalContainer=GContext.container; var isolated=new Container();
try
{ {
var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData")); throw new InvalidOperationException("Idle editor required for source inspection.");
var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json"))));
isolated.RegisterInstance<cfg.Tables>(tables); EquipmentContextAccess.Use(isolated);
var now=DateTime.UtcNow;
var snapshot=PlayerKVGameplayBindings.Snapshot(now);
// Diagnostic-only context: this is NOT a real KV Prepare/Open and cannot publish a handle.
var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,id,1,ownerId:"offline-PF-candidate",data:raw);
var account=new PlayerKVAccountDescriptor(id,1,0,false,DateTime.SpecifyKind(DateTime.MinValue,DateTimeKind.Utc),now);
var identity=new PlayerKVIdentitySnapshot((string)source["playFabId"],false,PlayerKVIdentityChannel.none,Array.Empty<PlayerKVIdentityChannel>());
var decoded=PlayerKVSaveSchema.Decode(prepared,account,id,1,true,snapshot,snapshot.Version,identity);
return JsonConvert.SerializeObject(new{sourceOnly=true,syntheticDescriptor=true,usesLocalUtc=true,customId=id,path,sha256=PlayerKVLoopPermit.Hash(bytes),keys=raw.Count,
verdict=decoded.Verdict.ToString(),errors=decoded.Diagnostics.TotalErrors,
details=decoded.Diagnostics.Details.Select(d=>new{module=d.Module,field=d.Field,code=d.Code.ToString()}),
configuredRods=decoded.Data?.Module<PlayerKVFishState>(3).RodLevels.Keys.ToArray(),
gameplayPublished=false,writes=0});
} }
finally { EquipmentContextAccess.Use(originalContainer); isolated.Dispose(); }
path = Path.GetFullPath(path);
var allowed = Path.GetFullPath(Path.Combine(Root, "pf-source-backups")) + Path.DirectorySeparatorChar;
if (!path.StartsWith(allowed, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Expected local PF source backup.");
}
var bytes = File.ReadAllBytes(path);
var source = JObject.Parse(Encoding.UTF8.GetString(bytes));
var id = (string)source["customId"];
if ((string)source["source"] != "PlayFab" || (string)source["titleId"] != "108EE8" || (id != "101" && id != "102"))
{
throw new InvalidOperationException("Source identity mismatch.");
}
var raw = source["data"].ToObject<Dictionary<string, string>>();
var actual = Module02OriginalLoadTests.InspectRaw(raw);
return JsonConvert.SerializeObject(new { sourceOnly = true, usesLocalUtc = true, customId = id, path, sha256 = PlayerKVLoopPermit.Hash(bytes), keys = raw.Count, originalLoad = JObject.Parse(actual), gameplayPublished = false, formalRequests = 0 });
} }
sealed class EquipmentFixture sealed class EquipmentFixture
{ {
@@ -514,108 +604,35 @@ namespace game.PlayerDataStorage
internal async Task Close() internal async Task Close()
{ {
ReleaseSet.TrySetResult(true); ReleaseSet.TrySetResult(true);
await bridge.CloseAsync(); Exception closeError = null;
session.Dispose(); try
{
await bridge.CloseAsync();
}
catch (Exception error)
{
closeError = error;
throw;
}
finally
{
try
{
session.Dispose();
}
catch (Exception disposeError)
{
if (closeError != null)
{
throw new AggregateException(closeError, disposeError);
}
throw;
}
}
} }
} }
} }
#endif #endif
} }
#if !SERVICE_HOST
namespace game
{
public partial class PlayFabUserService
{
PlayerKVGameSessionBridge loopBridge;
PlayerKVGameLoadGuard loopGuard;
Task loopClosing;
TaskCompletionSource<bool> loopSettled;
bool loopAttempted, loopStopping;
internal bool KVLoopLoading { get; private set; }
async Task<ELoginStep> LoginKVGameplayLoopAsync(Action<int> progress,int generation)
{
if(loopAttempted || loopStopping || CustomId!=PlayerKVGameplayLoop.Permit.PlayerId) return ELoginStep.Api;
loopAttempted=true; loopSettled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
loopBridge=new PlayerKVGameSessionBridge();
PlayerKVGameplayLoop.Attach(message=>
{
_=StopKVGameplayLoopAsync();
var main=UnityEngine.Application.isPlaying;
if(main) _=ExitKVGameplayLoopAsync();
},StopKVGameplayLoopAsync);
try
{
if(customServerMgr.ApiBaseUri.AbsoluteUri!=PlayerKVGameplayLoop.Permit.Api)
throw new InvalidOperationException("KV loop requires the fixed s101 API.");
if(!await LoginPlayFab(generation) || generation!=logoutGeneration) throw new InvalidOperationException("PF identity login failed/stale.");
if(!await LoginApiServer() || generation!=logoutGeneration) throw new InvalidOperationException("API login failed/stale.");
if(InternalId!=0 || IsLogin) throw new InvalidOperationException("Gameplay identity published before load.");
var opened=await loopBridge.OpenAsync(apiSession,PlayerKVGameplayLoop.CacheRoot,CustomId,customServerMgr.ApiBaseUri,
generation,e=>!loopStopping && e==logoutGeneration,LoginLifetime);
if(!opened.Success) throw new InvalidOperationException("KV open: "+opened.Error);
var read=opened.Handle.ReadLocalSnapshot();
if(!read.Success) throw new InvalidOperationException("KV snapshot: "+read.Error);
var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,apiSession.PlayerId,generation,
syncStatus:opened.Handle.Status,ownerId:opened.Handle.OwnerInstanceId,data:read.Data);
var account=new PlayerKVAccountDescriptor(apiSession.PlayerId,generation,apiSession.InternalId,
apiSession.NewlyCreated,apiSession.CreateTimeUtc,apiSession.ServerTimeUtc);
var snapshot=PlayerKVGameplayBindings.Snapshot(apiSession.ServerTimeUtc);
var identity=new PlayerKVIdentitySnapshot(UserId,config.Get<bool>("NewlyCreated",false),PlayerKVIdentityChannel.none,Array.Empty<PlayerKVIdentityChannel>());
var decoded=PlayerKVSaveSchema.Decode(prepared,account,CustomId,generation,!loopStopping && generation==logoutGeneration,snapshot,snapshot.Version,identity);
if(decoded.Data==null) throw new InvalidOperationException("KV schema: "+decoded.Verdict);
if(!PlayerKVGameLoadGuard.TryEnter(out loopGuard)) throw new InvalidOperationException("Load guard occupied.");
KVLoopLoading=true;
var transaction=PlayerKVGameplayBindings.Bind(this);
var applied=transaction.Apply(decoded.Data,()=>!loopStopping && generation==logoutGeneration,
()=>loopGuard.IsCurrent && loopGuard.Violations==0,()=>{});
if(applied!=PlayerKVApplyError.None) throw new InvalidOperationException("KV apply: "+applied);
KVLoopLoading=false; loopGuard.Dispose(); loopGuard=null;
PlayerKVGameplayLoop.Publish(opened.Handle);
PlayFabMgr.Instance.StartKVGameplayLoopSync();
InternalId=apiSession.InternalId;
GContext.Publish(new EvtAPISvrLoginSuccess{playerId=apiSession.PlayerId,internalId=InternalId});
OnInitData?.Invoke(decoded.Data.Raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal));
SetMMTestValue();
if(!PlayerKVGameplayLoop.Ready || loopStopping || generation!=logoutGeneration) throw new InvalidOperationException("Initialization revoked loop.");
progress?.Invoke(2); return ELoginStep.Done;
}
catch(Exception e) { PlayerKVGameplayLoop.Fault(e.Message); return ELoginStep.Api; }
finally { KVLoopLoading=false; loopSettled.TrySetResult(true); }
}
async Task ExitKVGameplayLoopAsync()
{
try { await GContext.container.Resolve<IStageService>().SwitchStageAsync("QuitStage"); }
catch(Exception e) { Debug.LogException(e); }
}
internal Task StopKVGameplayLoopAsync()
{
if(loopClosing!=null) return loopClosing;
loopStopping=true; PlayerKVGameplayLoop.Revoke(); logoutGeneration++; apiLoginGeneration++; InternalId=0;
PlayFabMgr.Instance?.StopKVGameplayLoopSync();
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
loopClosing=completion.Task; PlayerKVShutdownRegistry.Process.Register(loopClosing);
try { loginLifetime.Cancel(); } catch(Exception e) { Debug.LogException(e); }
var drain=loopBridge?.CloseAsync() ?? Task.CompletedTask;
_=FinishKVGameplayLoopAsync(drain,completion); return loopClosing;
}
async Task FinishKVGameplayLoopAsync(Task drain,TaskCompletionSource<bool> completion)
{
try
{
if(loopSettled!=null) await loopSettled.Task;
await drain;
if(loopBridge!=null) await loopBridge.CloseAsync();
apiSession?.Dispose(); apiSession=null; customServerMgr.ClearAccountSession();
loopGuard?.Dispose(); loopGuard=null;
UserId=null; DisplayName=null; AvatarUrl=null; InternalId=0;
PlayFab.PlayFabClientAPI.ForgetAllCredentials();
PlayerKVGameplayLoop.Audit("closed",new{requiresProcessRestart=true}); completion.TrySetResult(true);
}
catch(Exception e) { completion.TrySetException(e); Debug.LogException(e); }
}
}
}
#endif
#endif #endif

View File

@@ -1,123 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using asap.core;
using game.PlayerDataStorage;
using UnityEngine;
namespace game
{
public partial class PlayFabUserService
{
readonly bool kvControlledInstance;
PlayerKVGameLoadCoordinator kvCoordinator;
Action<IDictionary<string,string>> kvReceiver;
int kvReceiverUsers;
internal PlayerKVGameLoadError KVLastError { get; private set; }
internal string CaptureSaveBackend() => config==null ? "playfab" : config.Get<string>("SaveBackend","playfab");
internal bool RejectSaveBackend(string backend)
{
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerKVGameplayLoop.Enabled && backend=="kv-loop" && !kvControlledInstance)
{ KVLastError=PlayerKVGameLoadError.None; return false; }
#endif
KVLastError=backend=="kv" ? PlayerKVGameLoadError.BackendNotEnabled : backend!="playfab" ? PlayerKVGameLoadError.BackendUnsupported :
kvControlledInstance ? PlayerKVGameLoadError.Input : PlayerKVGameLoadError.None;
return KVLastError!=PlayerKVGameLoadError.None;
}
internal void ConfigureKVForValidation(PlayerKVGameLoadTransaction transaction,PlayFabMgr pf,CustomServerMgr server,Func<bool> unstarted)
{
if(!kvControlledInstance || kvCoordinator!=null || pf==null || !pf.IsUnstartedForKVLoad || PlayFabMgr.HasLiveInstanceForKVLoad ||
server==null || !server.IsFreshForKVLoad || unstarted==null || !unstarted()) throw new InvalidOperationException("Fresh controlled services required.");
kvCoordinator=new PlayerKVGameLoadCoordinator(transaction,()=>pf.IsUnstartedForKVLoad && InternalId==0 && unstarted(),
NotifyKVLoaded,()=>KVIntents?.Count ?? 0,server.BindKVForValidation,server.ClearKVForValidation);
server.KVUnauthorized+=version=> { if(version==server.SessionVersion) { InternalId=0; _=kvCoordinator.Revoke(); } };
}
internal virtual async Task<PlayerKVGameLoadResult> LoadKVForValidationAsync(PlayerKVGameLoadCapability capability)
{
if(!kvControlledInstance || kvCoordinator==null || capability==null)
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0);
try
{
int loginMs=config==null ? 10000 : PlayerKVSaveSchema.Int32(config.Get<string>("KVLoginCloseObserveMs","10000"));
int quitMs=config==null ? 5000 : PlayerKVSaveSchema.Int32(config.Get<string>("KVQuitCloseObserveMs","5000"));
if(loginMs!=capability.LoginObserveMs || quitMs!=capability.QuitObserveMs || loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000)
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0);
}
catch { return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0); }
var requestedId=CustomId; var generation=logoutGeneration;
var result=await kvCoordinator.LoadAsync(requestedId,capability,LoginLifetime,()=>requestedId==CustomId && generation==logoutGeneration);
if(generation!=logoutGeneration || requestedId!=CustomId)
{
_=kvCoordinator.Revoke();
if(result.State==PlayerKVGameLoadState.LoadedForValidation) result=new PlayerKVGameLoadResult(PlayerKVGameLoadError.Stale,result.Epoch);
}
KVLastError=result.Error; return result;
}
internal Task CloseKVForValidationAsync() => kvCoordinator?.Revoke() ?? Task.CompletedTask;
internal Task<PlayerKVShutdownStatus> ObserveKVQuitAsync() => kvCoordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready);
internal bool KVLoadedForValidation => kvCoordinator?.LoadedForValidation ?? false;
internal int KVReceiverCountForValidation => kvReceiverUsers;
internal void EnterKVReceiver(Action<IDictionary<string,string>> receiver)
{
if(!kvControlledInstance || receiver==null || (kvReceiver!=null && kvReceiver!=receiver)) throw new InvalidOperationException("Controlled receiver required.");
if(kvReceiverUsers++==0) { kvReceiver=receiver; OnInitData+=receiver; }
}
internal void ExitKVReceiver()
{ if(--kvReceiverUsers==0) { OnInitData-=kvReceiver; kvReceiver=null; } }
void NotifyKVLoaded()
{
var listeners=OnInitData?.GetInvocationList();
if(listeners!=null && (listeners.Length!=1 || kvReceiver==null || !listeners[0].Equals(kvReceiver)))
throw new InvalidOperationException("Unregistered controlled receiver.");
OnInitData?.Invoke(KVLoadingCopy.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal));
}
internal int KVTapJoyBalance { get; private set; }
internal PlayerKVInitializationIntents KVIntents { get; private set; }
internal IReadOnlyDictionary<string,string> KVLoadingCopy { get; private set; }
internal int MMTestForKVValidation => MMTestValue;
internal bool IsControlledKVInstance => kvControlledInstance;
internal virtual void ResetForKVLoad()
{
bool admitted=kvControlledInstance, sessionAllowed=apiSession==null;
#if UNITY_EDITOR && !SERVICE_HOST
admitted=admitted || KVLoopLoading;
sessionAllowed=sessionAllowed || KVLoopLoading;
#endif
if(!admitted || InternalId!=0 || !sessionAllowed)
throw new InvalidOperationException("Unstarted controlled identity required.");
KVIntents?.Discard(); KVIntents=null; KVLoadingCopy=null;
MMTestValue=0; EAccoutUrl=new Dictionary<string,string>(StringComparer.Ordinal); KVTapJoyBalance=0;
}
internal void ReplaceFromKVDecoded(PlayerKVLoginState state,long epoch,IReadOnlyDictionary<string,string> raw)
{
bool admitted=kvControlledInstance;
#if UNITY_EDITOR && !SERVICE_HOST
admitted=admitted || KVLoopLoading;
#endif
if(!admitted || state==null || raw==null) throw new InvalidOperationException("Controlled login state required.");
var copy=raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
var intents=new PlayerKVInitializationIntents(epoch,state.Channel);
foreach(var pair in state.Normalizations) { copy[pair.Key]=pair.Value; intents.Add(epoch,pair.Key,pair.Value); }
KVIntents?.Discard(); KVIntents=intents;
KVLoadingCopy=new System.Collections.ObjectModel.ReadOnlyDictionary<string,string>(copy);
MMTestValue=state.MMTest; KVTapJoyBalance=state.TapJoyBalance;
EAccoutUrl=state.AccountUrls.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
ReplaceKVLinked(state);
}
void IKVUserLoadReceiver.ResetForKVLoad() => ResetForKVLoad();
void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state,long epoch,IReadOnlyDictionary<string,string> raw)
=> ReplaceFromKVDecoded(state,epoch,raw);
void IKVUserLoadReceiver.DiscardKVIntents() => KVIntents?.Discard();
bool IKVUserLifecycle.KVHandoffReady => false;
PlayerKVGameLoadError IKVUserLifecycle.KVLastError => KVLastError;
Task IKVUserLifecycle.CloseKVForValidationAsync() => CloseKVForValidationAsync();
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
internal virtual void ReplaceKVLinked(PlayerKVLoginState state) { }
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a676add461b14c90b0f8a6e420884fd6

View File

@@ -98,7 +98,7 @@ namespace game
void UpdateProfileLv(); void UpdateProfileLv();
} }
public partial class PlayFabUserService : IUserService, PlayerDataStorage.IKVUserLoadReceiver, PlayerDataStorage.IKVUserLifecycle public class PlayFabUserService : IUserService
{ {
[Inject] [Inject]
public IConfig config { get; set; } public IConfig config { get; set; }
@@ -106,7 +106,7 @@ namespace game
[Inject] [Inject]
public ICustomServerMgr customServerMgr { get; set; } public ICustomServerMgr customServerMgr { get; set; }
public bool IsLogin => !kvControlledInstance && PlayFabClientAPI.IsClientLoggedIn() && IsAuthorized; public bool IsLogin => PlayFabClientAPI.IsClientLoggedIn() && IsAuthorized;
public bool IsAuthorized => InternalId != 0 && apiSession != null && apiSession.IsAuthenticated; public bool IsAuthorized => InternalId != 0 && apiSession != null && apiSession.IsAuthenticated;
@@ -145,18 +145,11 @@ namespace game
return "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length))); return "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length)));
} }
public PlayFabUserService() : this(null) { } public PlayFabUserService()
internal PlayFabUserService(PlayerDataStorage.PlayerKVControlledLoadCapability capability)
{ {
kvControlledInstance = capability != null;
if (kvControlledInstance) return;
GContext.OnEvent<EvtAPISvrUnauthorized>().Subscribe(evt => GContext.OnEvent<EvtAPISvrUnauthorized>().Subscribe(evt =>
{ {
if (evt.SessionVersion != customServerMgr.SessionVersion) return; if (evt.SessionVersion != customServerMgr.SessionVersion) return;
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault("API unauthorized; restart required."); return; }
#endif
InternalId = 0; InternalId = 0;
if (PlayFabClientAPI.IsClientLoggedIn()) _ = LoginApiServer(); if (PlayFabClientAPI.IsClientLoggedIn()) _ = LoginApiServer();
}); });
@@ -168,6 +161,16 @@ namespace game
private readonly System.Threading.SemaphoreSlim loginGate = new System.Threading.SemaphoreSlim(1, 1); private readonly System.Threading.SemaphoreSlim loginGate = new System.Threading.SemaphoreSlim(1, 1);
public virtual Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true) => LoginWithBackend(progress,CaptureSaveBackend()); public virtual Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true) => LoginWithBackend(progress,CaptureSaveBackend());
internal string CaptureSaveBackend()
{
return config == null ? "playfab" : config.Get<string>("SaveBackend", "playfab");
}
internal bool RejectSaveBackend(string backend)
{
return backend != "playfab";
}
protected async Task<ELoginStep> LoginWithBackend(Action<int> progress,string backend) protected async Task<ELoginStep> LoginWithBackend(Action<int> progress,string backend)
{ {
var generation = logoutGeneration; var generation = logoutGeneration;
@@ -189,9 +192,6 @@ namespace game
private async Task<ELoginStep> LoginCore(Action<int> progress, int generation) private async Task<ELoginStep> LoginCore(Action<int> progress, int generation)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return await LoginKVGameplayLoopAsync(progress,generation);
#endif
if (IsLogin) if (IsLogin)
{ {
progress?.Invoke(2); progress?.Invoke(2);
@@ -351,11 +351,7 @@ namespace game
var info_request_parameters = new GetPlayerCombinedInfoRequestParams var info_request_parameters = new GetPlayerCombinedInfoRequestParams
{ {
GetUserAccountInfo = true, GetUserAccountInfo = true,
#if UNITY_EDITOR && !SERVICE_HOST
GetUserData = !game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled,
#else
GetUserData = true, GetUserData = true,
#endif
GetPlayerProfile = true, GetPlayerProfile = true,
ProfileConstraints = new PlayerProfileViewConstraints() ProfileConstraints = new PlayerProfileViewConstraints()
{ {
@@ -518,9 +514,6 @@ namespace game
//InitData should invoke after local time adjusted. //InitData should invoke after local time adjusted.
//save login time to local storage. //save login time to local storage.
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
InitData(playfabUserData, UserId); InitData(playfabUserData, UserId);
return true; return true;
@@ -560,9 +553,6 @@ namespace game
return false; return false;
} }
customServerMgr.UseAccountSession(session); customServerMgr.UseAccountSession(session);
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true; // Identity is published only after all KV modules load.
#endif
InternalId = session.InternalId; InternalId = session.InternalId;
GContext.Publish(new EvtAPISvrLoginSuccess GContext.Publish(new EvtAPISvrLoginSuccess
{ {
@@ -672,17 +662,6 @@ namespace game
public virtual void Logout() public virtual void Logout()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=StopKVGameplayLoopAsync(); return; }
#endif
if(kvControlledInstance)
{
logoutGeneration++; InternalId=0; UserId=null; DisplayName=null; AvatarUrl=null;
_=kvCoordinator?.Revoke();
var previous=loginLifetime; loginLifetime=new System.Threading.CancellationTokenSource();
try { previous.Cancel(); } finally { previous.Dispose(); }
return;
}
logoutGeneration++; logoutGeneration++;
apiLoginGeneration++; apiLoginGeneration++;

View File

@@ -199,20 +199,8 @@ namespace game
InitManager(); InitManager();
if (kvUser != null) if (kvUser != null)
{ {
if (!GContext.container.Resolve<TimeManager.TimeRefreshManager>().InitialRefreshSucceeded) var loginTime = kvUser.LoginTime.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
throw new System.InvalidOperationException("KV initial daily refresh failed; previous login must be retained."); _ = ObserveLastLoginSaveAsync(store, loginTime);
// Daily refresh consumes the previous login first. Save its actual player aggregate
// and pending module writes before advancing the persisted login timestamp.
GContext.container.Resolve<PlayerData>().SavePlayerData();
var initialization = await store.FlushAsync(loginLifetime);
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
if (!kvUser.IsAuthorized || !store.IsCurrent || !initialization.Confirmed)
throw new System.InvalidOperationException("KV initialization saves were not confirmed.");
var loginSaved = await store.WriteAndConfirmAsync("LastLoginTimeUtc",
kvUser.LoginTime.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture), loginLifetime);
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
if (!kvUser.IsAuthorized || !store.IsCurrent || !loginSaved.Confirmed)
throw new System.InvalidOperationException("KV login timestamp was not confirmed.");
} }
GContext.container.RegisterInstance<IStageData>(stageData); GContext.container.RegisterInstance<IStageData>(stageData);
GContext.container.Register<SurveyService>().AsSingleton(); GContext.container.Register<SurveyService>().AsSingleton();
@@ -271,6 +259,18 @@ namespace game
_autoRestart.Init(); _autoRestart.Init();
} }
private static async Task ObserveLastLoginSaveAsync(PlayerDataStorage.IPlayerSaveStore store, string loginTime)
{
try
{
await store.SyncDataImmidiate("LastLoginTimeUtc", loginTime);
}
catch (System.Exception ex)
{
Debug.LogError("[FishingStage] LastLogin save failed: " + ex);
}
}
void InitEvent() void InitEvent()
{ {
var rtService = GContext.container.Resolve<IRTService>(); var rtService = GContext.container.Resolve<IRTService>();
@@ -282,26 +282,14 @@ namespace game
MainThreadDispatcher.OnApplicationPauseAsObservable() MainThreadDispatcher.OnApplicationPauseAsObservable()
.Subscribe(paused => .Subscribe(paused =>
{ {
if (paused) _ = FlushPausedSaveAsync(); if (!paused)
else _ = OnApplicationResumedAsync(); {
_ = OnApplicationResumedAsync();
}
}) })
.AddTo(disposable); .AddTo(disposable);
} }
private async Task FlushPausedSaveAsync()
{
var user = UserService;
var store = sessionSaveStore;
if (stopping || user is not KVUserService || !user.IsAuthorized || store == null || !store.IsCurrent) return;
try
{
var result = await store.FlushAsync(user.LoginLifetime);
if (!stopping && store.IsCurrent && !result.Confirmed)
Debug.LogWarning("KV pause save: " + result.Status);
}
catch (System.Exception ex) { Debug.LogError("KV pause save failed: " + ex.GetType().Name); }
}
private async Task OnApplicationResumedAsync() private async Task OnApplicationResumedAsync()
{ {
var userService = UserService; var userService = UserService;

View File

@@ -19,34 +19,39 @@ namespace game
{ {
#if AGG #if AGG
GContext.container.Register<IIAPService, AggIAPService>().AsSingleton(); GContext.container.Register<IIAPService, AggIAPService>().AsSingleton();
GContext.container.Register<IUserService, AggUserService>().AsSingleton();
GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.PlayFabPlayerSaveStore>().AsSingleton();
#else #else
GContext.container.Register<IIAPService, DevIAPService>().AsSingleton();
#endif
if (SubBoostrap.RuntimeSelection?.IsKV == true) if (SubBoostrap.RuntimeSelection?.IsKV == true)
{ {
if (PlayFabMgr.HasLiveInstanceForKVLoad) if (PlayFabMgr.HasLiveInstanceForKVLoad)
{
throw new System.InvalidOperationException("KV startup found an active legacy save manager."); throw new System.InvalidOperationException("KV startup found an active legacy save manager.");
GContext.container.Register<IUserService, KVUserService>().AsSingleton(); }
#if AGG
GContext.container.Register<IUserService, AggKVUserService>().AsSingleton();
#else
GContext.container.Register<IUserService, DevKVUserService>().AsSingleton();
#endif
GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.KVPlayerSaveStore>().AsSingleton(); GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.KVPlayerSaveStore>().AsSingleton();
} }
else else
{ {
#if AGG
GContext.container.Register<IUserService, AggUserService>().AsSingleton();
#else
GContext.container.Register<IUserService, PlayFabUserService>().AsSingleton(); GContext.container.Register<IUserService, PlayFabUserService>().AsSingleton();
#endif
GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.PlayFabPlayerSaveStore>().AsSingleton(); GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.PlayFabPlayerSaveStore>().AsSingleton();
} }
GContext.container.Register<IIAPService, DevIAPService>().AsSingleton();
#endif
GContext.container.Register<PlayerFishData>().AsSingleton(); GContext.container.Register<PlayerFishData>().AsSingleton();
GContext.container.Register<PlayerItemData>().AsSingleton(); GContext.container.Register<PlayerItemData>().AsSingleton();
GContext.container.Register<CampDataMM>().AsSingleton(); GContext.container.Register<CampDataMM>().AsSingleton();
GContext.container.Register<FishingEventData>().AsSingleton(); GContext.container.Register<FishingEventData>().AsSingleton();
#if UNITY_EDITOR && !SERVICE_HOST if (SubBoostrap.RuntimeSelection?.IsKV == true)
if(PlayerDataStorage.PlayerKVGameplayLoop.Enabled || SubBoostrap.RuntimeSelection?.IsKV==true) {
GContext.container.Register<EventShootingRangeData>().AsSingleton(); GContext.container.Register<EventShootingRangeData>().AsSingleton();
#else }
if(SubBoostrap.RuntimeSelection?.IsKV==true)
GContext.container.Register<EventShootingRangeData>().AsSingleton();
#endif
GContext.container.Register<SigninData>().AsSingleton(); GContext.container.Register<SigninData>().AsSingleton();
//GContext.container.Register<TaskData>().AsSingleton(); //GContext.container.Register<TaskData>().AsSingleton();
GContext.container.Register<ClubService>().AsSingleton(); GContext.container.Register<ClubService>().AsSingleton();
@@ -107,9 +112,6 @@ namespace game
startActId = "AggLoginAct"; startActId = "AggLoginAct";
#else #else
startActId = "PFLoginAct"; startActId = "PFLoginAct";
#endif
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) startActId="PFLoginAct";
#endif #endif
if (!LoadingScreen.isShowing()) if (!LoadingScreen.isShowing())
{ {
@@ -123,8 +125,6 @@ namespace game
protected override async Task RequestNextAct(string actId) protected override async Task RequestNextAct(string actId)
{ {
if(SubBoostrap.RuntimeSelection?.IsKV==true)
return;
await GContext.container.Resolve<IStageService>().SwitchStageAsync("FishingStage"); await GContext.container.Resolve<IStageService>().SwitchStageAsync("FishingStage");
} }
} }

View File

@@ -32,6 +32,7 @@ namespace game
private string[] loadingInfos; private string[] loadingInfos;
private CancellationTokenSource loginLifetime; private CancellationTokenSource loginLifetime;
private CancellationToken loginToken; private CancellationToken loginToken;
private Task loginWork;
public override async Task<bool> StartAsync() public override async Task<bool> StartAsync()
{ {
@@ -60,10 +61,22 @@ namespace game
//CancellationTokenSource source; //CancellationTokenSource source;
private async void DoLogin() private async void DoLogin()
{ {
try { await LoginAsync(); } try
catch (OperationCanceledException) when (loginToken.IsCancellationRequested) { } {
catch (Exception ex) { Debug.LogException(ex); } loginWork = LoginAsync();
finally { StopLoadingInfo(); } await loginWork;
}
catch (OperationCanceledException) when (loginToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
Debug.LogException(ex);
}
finally
{
StopLoadingInfo();
}
} }
private async Task LoginAsync() private async Task LoginAsync()
@@ -71,9 +84,6 @@ namespace game
loginToken.ThrowIfCancellationRequested(); loginToken.ThrowIfCancellationRequested();
UpdateLoadingInfo(); UpdateLoadingInfo();
var configCustomId = config.Get<string>("CustomID", string.Empty); var configCustomId = config.Get<string>("CustomID", string.Empty);
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) configCustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId;
#endif
if (configCustomId == @"{{input}}") if (configCustomId == @"{{input}}")
{ {
LoadingScreen.HideProgressBar(); LoadingScreen.HideProgressBar();
@@ -83,7 +93,7 @@ namespace game
LoadingScreen.ShowProgressBar(); LoadingScreen.ShowProgressBar();
UpdateLoadingInfo(); UpdateLoadingInfo();
} }
else else if (SubBoostrap.RuntimeSelection?.IsKV == true || string.IsNullOrEmpty(configCustomId))
{ {
userService.CustomId = configCustomId; userService.CustomId = configCustomId;
} }
@@ -91,37 +101,16 @@ namespace game
//source = new CancellationTokenSource(); //source = new CancellationTokenSource();
//LoadingAutoInc(source.Token, 1f); //LoadingAutoInc(source.Token, 1f);
var formalKV=SubBoostrap.RuntimeSelection?.IsKV==true;
rtService.WaitForAPISvrLogin(); rtService.WaitForAPISvrLogin();
var step = await userService.Login(OnLoginStep); var step = await userService.Login(OnLoginStep);
loginToken.ThrowIfCancellationRequested(); loginToken.ThrowIfCancellationRequested();
if(formalKV)
{
StopLoadingInfo();
LoadingScreen.HideProgressBar();
if(step==ELoginStep.Done && kvLifecycle?.KVHandoffReady==true)
LoadingScreen.SetLoadingInfo("KV account loaded. Gameplay is not enabled yet.");
else
{
var error=kvLifecycle?.KVLastError.ToString() ?? "Input";
dialog.Config(new SimpleDialog.Context
{
message="KV login unavailable",
message2="Login stopped ("+error+"). Sign out or restart before trying again.",
okAction=()=>userService.Logout(), closeAction=()=>userService.Logout()
});
}
return;
}
if (step != ELoginStep.Done) if (step != ELoginStep.Done)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Login failed; restart the Unity process before another attempt.");
#endif
//source.Cancel(); //source.Cancel();
//source.Dispose(); //source.Dispose();
await ReytryDialog(userService.Login, LocalizationMgr.GetText("subtitle_loginfailed") await ReytryDialog(userService.Login, LocalizationMgr.GetText("subtitle_loginfailed")
, LocalizationMgr.GetText("msg_loginfailed")); , LocalizationMgr.GetText("msg_loginfailed"));
step = ELoginStep.Done;
} }
loginToken.ThrowIfCancellationRequested(); loginToken.ThrowIfCancellationRequested();
@@ -310,7 +299,11 @@ namespace game
loginLifetime?.Cancel(); loginLifetime?.Cancel();
loginLifetime?.Dispose(); loginLifetime?.Dispose();
loginLifetime = null; loginLifetime = null;
if(SubBoostrap.RuntimeSelection?.IsKV==true) userService.Logout(); if (SubBoostrap.RuntimeSelection?.IsKV == true && loginWork != null &&
(loginWork.Status != TaskStatus.RanToCompletion || kvLifecycle?.KVHandoffReady != true))
{
userService.Logout();
}
} }

View File

@@ -34,10 +34,25 @@ namespace game
public override async Task<bool> StartAsync() public override async Task<bool> StartAsync()
{ {
#if UNITY_EDITOR && !SERVICE_HOST if (userService is PlayerDataStorage.IKVUserLifecycle closingKVUser)
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) _=game.PlayerDataStorage.PlayerKVGameplayLoop.CloseAsync(); {
#endif try
if(userService is PlayerDataStorage.IKVUserLifecycle closingKVUser) _=closingKVUser.CloseKVForValidationAsync(); {
var original = (userService as KVUserService)?.SaveStore as PlayerDataStorage.KVPlayerSaveStore;
if (original != null && original.IsCurrent)
{
_ = original.SyncDataAsync();
}
}
catch (System.Exception ex)
{
Debug.LogError("KV quit save failed: " + ex.GetType().Name);
}
finally
{
_ = closingKVUser.CloseKVForValidationAsync();
}
}
// Invalidate identity before any await can allow old login/HTTP callbacks to resume. // Invalidate identity before any await can allow old login/HTTP callbacks to resume.
if (QuitStage.Logout) if (QuitStage.Logout)
{ {
@@ -54,10 +69,6 @@ namespace game
catch (System.Exception ex) { Debug.LogException(ex); } catch (System.Exception ex) { Debug.LogException(ex); }
// Observation is bounded; the process registry still owns any unfinished close. // Observation is bounded; the process registry still owns any unfinished close.
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) await PlayerDataStorage.PlayerKVShutdownRegistry.Process.ObserveAsync(5000);
else
#endif
if(userService is PlayerDataStorage.IKVUserLifecycle kvUser) await kvUser.ObserveKVQuitAsync(); if(userService is PlayerDataStorage.IKVUserLifecycle kvUser) await kvUser.ObserveKVQuitAsync();
adsService.Release(); adsService.Release();
@@ -87,16 +98,6 @@ namespace game
} }
} }
base.OnStarted(); base.OnStarted();
#if UNITY_EDITOR && !SERVICE_HOST
if (game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
var status = PlayerDataStorage.PlayerKVShutdownRegistry.Process.Status;
Debug.Log("KV loop ended; restart Unity before another run. Shutdown: " + status);
if (status == PlayerDataStorage.PlayerKVShutdownStatus.Ready)
UnityEditor.EditorApplication.isPlaying = false;
return; // Keep the process barrier; never return this run to PreBoot.
}
#endif
Invoke("Unload", 2f); Invoke("Unload", 2f);
} }
} }

View File

@@ -1,4 +1,4 @@
using AppsFlyerSDK; using AppsFlyerSDK;
using asap.core; using asap.core;
using asap.core.common; using asap.core.common;
@@ -26,9 +26,12 @@ public class SubBoostrap
{ {
internal static game.PlayerDataStorage.PlayerKVRuntimeSelection RuntimeSelection { get; private set; } internal static game.PlayerDataStorage.PlayerKVRuntimeSelection RuntimeSelection { get; private set; }
public static async System.Threading.Tasks.Task RegisterServices(GameObject rootCtxGo, IConfig config) public static async Task RegisterServices(GameObject rootCtxGo, IConfig config)
{ {
if(config==null) throw new ArgumentNullException(nameof(config)); if (config == null)
{
throw new ArgumentNullException(nameof(config));
}
#if AGG #if AGG
const bool aggBuild=true; const bool aggBuild=true;
#else #else
@@ -97,10 +100,14 @@ public class SubBoostrap
#else #else
GameCore.GameEventMgr.Init(); GameCore.GameEventMgr.Init();
#endif #endif
#if UNITY_EDITOR && !SERVICE_HOST if (!selection.IsKV)
game.PlayerDataStorage.PlayerKVGameplayLoop.BeforeBootstrap(); {
#endif rootCtxGo.AddComponent<PlayFabMgr>();
if(!selection.IsKV) rootCtxGo.AddComponent<PlayFabMgr>(); }
else
{
rootCtxGo.AddComponent<KVMgr>();
}
// TODO Replace DataCenter with seprated data class // TODO Replace DataCenter with seprated data class
// Create(); // Create();
// ----------------------------------------- // -----------------------------------------

View File

@@ -20,7 +20,7 @@ namespace TimeManager
_sessionStartTime = ZZTimeHelper.UtcNow().UtcNowOffset(); _sessionStartTime = ZZTimeHelper.UtcNow().UtcNowOffset();
_lastCheckedHour = _sessionStartTime.Hour; _lastCheckedHour = _sessionStartTime.Hour;
InitialRefreshSucceeded = CheckLastLogin(); CheckLastLogin();
_tickService.MinuteTick _tickService.MinuteTick
.Subscribe(_ => .Subscribe(_ =>
{ {
@@ -32,7 +32,6 @@ namespace TimeManager
private readonly ITimeTickService _tickService; private readonly ITimeTickService _tickService;
private readonly IUserService _userService; private readonly IUserService _userService;
public bool InitialRefreshSucceeded { get; }
private int _lastCheckedDayOfYear = -1; private int _lastCheckedDayOfYear = -1;
private int _lastCheckedHour = -1; private int _lastCheckedHour = -1;
@@ -41,11 +40,14 @@ namespace TimeManager
#region Check登录时间 #region Check登录时间
private bool CheckLastLogin() private void CheckLastLogin()
{ {
if (IsLastLoginToday()) return true; if (IsLastLoginToday())
{
return;
}
Debug.Log("上次登录不是今天"); Debug.Log("上次登录不是今天");
return OnCrossDayRefresh(); OnCrossDayRefresh();
} }
private void CheckSessionCrossDay() private void CheckSessionCrossDay()
{ {
@@ -123,18 +125,16 @@ namespace TimeManager
/// <summary> /// <summary>
/// 跨天刷新逻辑 /// 跨天刷新逻辑
/// </summary> /// </summary>
private bool OnCrossDayRefresh() private void OnCrossDayRefresh()
{ {
try try
{ {
// 1. 发送活动跨天刷新事件 // 1. 发送活动跨天刷新事件
GContext.Publish(new ActivityCrossDayRefreshEvent()); GContext.Publish(new ActivityCrossDayRefreshEvent());
return true;
} }
catch (Exception e) catch (Exception e)
{ {
Debug.LogError($"[TimeRefreshManager] Error during cross day refresh: {e.Message}\n{e.StackTrace}"); Debug.LogError($"[TimeRefreshManager] Error during cross day refresh: {e.Message}\n{e.StackTrace}");
return false;
} }
} }

View File

@@ -581,8 +581,11 @@ public class DeBugPanel : MonoBehaviour
private void ChangeMMT() private void ChangeMMT()
{ {
var userService = GContext.container.Resolve<game.IUserService>() as game.PlayFabUserService; var userService = GContext.container.Resolve<game.IUserService>();
if (userService == null) return; if (userService is not game.PlayFabUserService && userService is not game.KVUserService)
{
return;
}
string mmt = inputField_mmt.text; string mmt = inputField_mmt.text;
List<string> mmts = mmt.ToLower().Select(c => c.ToString()).ToList(); List<string> mmts = mmt.ToLower().Select(c => c.ToString()).ToList();
var mmDatas = GContext.container.Resolve<Tables>().TbMMTInit.DataList; var mmDatas = GContext.container.Resolve<Tables>().TbMMTInit.DataList;
@@ -594,7 +597,14 @@ public class DeBugPanel : MonoBehaviour
value |= 1 << (mmDatas[i].ID - 1001); value |= 1 << (mmDatas[i].ID - 1001);
} }
} }
userService.GMMMT(value); if (userService is game.KVUserService kvUserService)
{
kvUserService.GMMMT(value);
}
else
{
((game.PlayFabUserService)userService).GMMMT(value);
}
} }
private string _cfg_ver; private string _cfg_ver;

View File

@@ -17,24 +17,6 @@ using UnityEngine.UI;
public class FishingRewardPanel : BasePanel public class FishingRewardPanel : BasePanel
{ {
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.FishingRewardAction kvReward;
System.Func<System.Threading.Tasks.Task> kvPresentation;
bool KVRewardViewCurrent() => !game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled ||
(this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.RewardPanelCurrent(kvReward,this));
async System.Threading.Tasks.Task ConfirmKVRewardAsync(game.PlayerDataStorage.PlayerKVEditResult primary)
{
try
{
await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmRewardAsync(kvReward,primary);
if(!KVRewardViewCurrent()) return;
game.PlayerDataStorage.PlayerKVGameplayLoop.PresentReward(kvReward,()=>{canvasGroup.alpha=1;canvasGroup.blocksRaycasts=true;canvasGroup.interactable=true;});
if(kvPresentation!=null) await kvPresentation();
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
}
void OnDisable() => game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"reward panel disabled");
#endif
Tables _tables; Tables _tables;
private CanvasGroup canvasGroup; private CanvasGroup canvasGroup;
private CanvasGroup root_success; private CanvasGroup root_success;
@@ -163,9 +145,6 @@ public class FishingRewardPanel : BasePanel
dragImage = transform.Find("root_success/UIDrag").GetComponent<UIDrag>(); dragImage = transform.Find("root_success/UIDrag").GetComponent<UIDrag>();
canvasGroup = GetComponent<CanvasGroup>(); canvasGroup = GetComponent<CanvasGroup>();
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { canvasGroup.alpha=0; canvasGroup.blocksRaycasts=false; canvasGroup.interactable=false; }
#endif
root_success = transform.Find("root_success").GetComponent<CanvasGroup>(); root_success = transform.Find("root_success").GetComponent<CanvasGroup>();
@@ -263,9 +242,6 @@ public class FishingRewardPanel : BasePanel
} }
if (GContext.container.Resolve<FishingEventData>().IsOpenDrop) if (GContext.container.Resolve<FishingEventData>().IsOpenDrop)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
CheckResource(); CheckResource();
} }
} }
@@ -284,12 +260,8 @@ public class FishingRewardPanel : BasePanel
fishingData = GContext.container.Resolve<FishingData>(); fishingData = GContext.container.Resolve<FishingData>();
Magnification = playerData.GetMagnification(); Magnification = playerData.GetMagnification();
playButton.onClick.AddListener(OnClickInHome);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
{
go_fishPhoto.OnBackClick = OnBackClick; go_fishPhoto.OnBackClick = OnBackClick;
playButton.onClick.AddListener(OnClickInHome);
targetBtn.onClick.AddListener(OnTargetClosePanel); targetBtn.onClick.AddListener(OnTargetClosePanel);
oneVOneBattleBtn.onClick.AddListener(OnTargetClosePanel); oneVOneBattleBtn.onClick.AddListener(OnTargetClosePanel);
btn_green.onClick.AddListener(OnSucceed); btn_green.onClick.AddListener(OnSucceed);
@@ -319,7 +291,6 @@ public class FishingRewardPanel : BasePanel
dragImage.OnDragCall += OnDragFish; dragImage.OnDragCall += OnDragFish;
dragImage.OnEndDragCall += StartDoRotate; dragImage.OnEndDragCall += StartDoRotate;
}
OnShow(); OnShow();
} }
void StartDoRotate() void StartDoRotate()
@@ -338,31 +309,6 @@ public class FishingRewardPanel : BasePanel
} }
void OnShow() void OnShow()
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
try
{
var bound=game.PlayerDataStorage.PlayerKVGameplayLoop.BindRewardPanel(this);
if(bound==null) return;
kvReward=bound;
game.PlayerDataStorage.PlayerKVEditResult primary;
using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("reward"))
{
OnShowCore();
playerFishData.SaveCaughtFishForKVLoop(fishID);
primary=playerData.SaveFishingRewardForKVLoop();
}
_=ConfirmKVRewardAsync(primary);
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
return;
}
#endif
OnShowCore();
}
void OnShowCore()
{ {
var normalRank = _tables.TbGlobalConfig.Rank; var normalRank = _tables.TbGlobalConfig.Rank;
var spRank = _tables.TbGlobalConfig.RankForSpFish; var spRank = _tables.TbGlobalConfig.RankForSpFish;
@@ -425,23 +371,13 @@ public class FishingRewardPanel : BasePanel
if (fishingData.fishingResult == FishingResult.Success) if (fishingData.fishingResult == FishingResult.Success)
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
AudioRewardPanel(); AudioRewardPanel();
root_fail.SetActive(false); root_fail.SetActive(false);
root_success.gameObject.SetActive(fishingData.IsGeneralFish()); root_success.gameObject.SetActive(fishingData.IsGeneralFish());
root_target.SetActive(fishingData.IsKeepsakeFish() || fishingData.IsPackItemFish()); root_target.SetActive(fishingData.IsKeepsakeFish() || fishingData.IsPackItemFish());
root_1v1battle.SetActive(fishingData.fishItem.IsSpinachItem()); root_1v1battle.SetActive(fishingData.fishItem.IsSpinachItem());
float pointBonus = playerFishData.GetRodAscendStats(); float pointBonus = playerFishData.GetRodAscendStats();
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) kvPresentation=ApplySuccess(pointBonus);
else
#endif
Success(pointBonus); Success(pointBonus);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
StartCoroutine(Blur()); StartCoroutine(Blur());
#if AGG #if AGG
@@ -482,9 +418,6 @@ public class FishingRewardPanel : BasePanel
} }
#endif #endif
GContext.Publish(new OnEventTriggerGuide(GetType().Name)); GContext.Publish(new OnEventTriggerGuide(GetType().Name));
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) game.PlayerDataStorage.PlayerKVGameplayLoop.RecordRewardGuideNotification();
#endif
fishingData.IsRetry = false; fishingData.IsRetry = false;
} }
else else
@@ -548,19 +481,11 @@ public class FishingRewardPanel : BasePanel
//Pulling 结束后 //Pulling 结束后
float time = fishingData.fishingBehaviorConf.blurDelay; float time = fishingData.fishingBehaviorConf.blurDelay;
yield return new WaitForSeconds(time); yield return new WaitForSeconds(time);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) yield break;
#endif
EnableFishingBlur(true); EnableFishingBlur(true);
} }
//钓鱼成功 //钓鱼成功
async void Success(float pointBonus) async void Success(float pointBonus)
{
var present=ApplySuccess(pointBonus);
if(present!=null) await present();
}
System.Func<System.Threading.Tasks.Task> ApplySuccess(float pointBonus)
{ {
go_info.gameObject.SetActive(false); go_info.gameObject.SetActive(false);
GContext.container.Resolve<PlayerData>().SaveFishingFrequency(); GContext.container.Resolve<PlayerData>().SaveFishingFrequency();
@@ -572,9 +497,6 @@ public class FishingRewardPanel : BasePanel
GContext.container.Resolve<PlayerItemData>().AddItemCount(curCollectingID, addCollecting, null); GContext.container.Resolve<PlayerItemData>().AddItemCount(curCollectingID, addCollecting, null);
MaterialChange(); MaterialChange();
} }
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
PlayAudioRewardFish(); PlayAudioRewardFish();
GContext.container.Resolve<BuffDataCenter>().EndCollectionTargetWeight(fishingData.fishItem.ID); GContext.container.Resolve<BuffDataCenter>().EndCollectionTargetWeight(fishingData.fishItem.ID);
fishingEventData.AddProgress(fishingData.fishItem.Quality, Magnification); fishingEventData.AddProgress(fishingData.fishItem.Quality, Magnification);
@@ -644,9 +566,6 @@ public class FishingRewardPanel : BasePanel
} }
GContext.Publish(new TargetAddData(dropPackageList.DropIDList[1], 0, add)); GContext.Publish(new TargetAddData(dropPackageList.DropIDList[1], 0, add));
var isSpecialFish = fishingData.curFishData.GeneralType == FishGeneralType.SpMapFish || fishingData.curFishData.GeneralType == FishGeneralType.EventFish; var isSpecialFish = fishingData.curFishData.GeneralType == FishGeneralType.SpMapFish || fishingData.curFishData.GeneralType == FishGeneralType.EventFish;
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
SetTextTitleAsync(proficiencyExtra, isSpecialFish); SetTextTitleAsync(proficiencyExtra, isSpecialFish);
SetFishWeight(fishingData.fishWeight); SetFishWeight(fishingData.fishWeight);
MMTService mmService = GContext.container.Resolve<MMTService>(); MMTService mmService = GContext.container.Resolve<MMTService>();
@@ -661,28 +580,6 @@ public class FishingRewardPanel : BasePanel
} }
} }
bonusReward.Show(Magnification); bonusReward.Show(Magnification);
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
return async ()=>
{
if(!KVRewardViewCurrent()) return;
AudioRewardPanel(); PlayAudioRewardFish(); SetTextTitleAsync(proficiencyExtra,isSpecialFish);
_ = StartCoroutine(Blur());
await PresentGeneralRewardAsync(glodBuff);
};
#endif
return ()=>PresentGeneralRewardAsync(glodBuff);
}
else if (fishingData.fishItem.IsSpinachItem())
{
//todo 1v1battle
root_1v1battle_text_info.text=LocalizationMgr.GetText("UI_GiftBargainPopupPanel_8");
}
return null;
}
async System.Threading.Tasks.Task PresentGeneralRewardAsync(float glodBuff)
{
_ = StartCoroutine(AddCash()); _ = StartCoroutine(AddCash());
//Show Fish Info UI //Show Fish Info UI
go_info.gameObject.SetActive(true); go_info.gameObject.SetActive(true);
@@ -696,11 +593,7 @@ public class FishingRewardPanel : BasePanel
anim_panel.Play("info_show_2"); anim_panel.Play("info_show_2");
await new WaitForSeconds(waitTime1 + duration1 + waitTime2); await new WaitForSeconds(waitTime1 + duration1 + waitTime2);
if (!isEnable if (!isEnable)
#if UNITY_EDITOR && !SERVICE_HOST
|| !KVRewardViewCurrent()
#endif
)
{ {
return; return;
} }
@@ -712,9 +605,6 @@ public class FishingRewardPanel : BasePanel
var addCash = add - newCash; var addCash = add - newCash;
DOTween.To(() => progress, x => progress = x, 1f, duration2).OnUpdate(() => DOTween.To(() => progress, x => progress = x, 1f, duration2).OnUpdate(() =>
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
text_gold_num.text = ConvertTools.GetNumberString2((int)(newCash + addCash * progress)); text_gold_num.text = ConvertTools.GetNumberString2((int)(newCash + addCash * progress));
text_num_buff.text = text_gold_num.text; text_num_buff.text = text_gold_num.text;
}).SetId("AddCash"); }).SetId("AddCash");
@@ -730,14 +620,17 @@ public class FishingRewardPanel : BasePanel
} }
VibrationData(); VibrationData();
fishingRewardSocial.ShowSocial(isSocial); fishingRewardSocial.ShowSocial(isSocial);
}
else if (fishingData.fishItem.IsSpinachItem())
{
//todo 1v1battle
root_1v1battle_text_info.text=LocalizationMgr.GetText("UI_GiftBargainPopupPanel_8");
}
} }
async void VibrationData() async void VibrationData()
{ {
await Awaiters.Seconds(2.5f); await Awaiters.Seconds(2.5f);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
if (curRateIndex >= _tables.TbGlobalConfig.RankVibration.Count) if (curRateIndex >= _tables.TbGlobalConfig.RankVibration.Count)
curRateIndex = _tables.TbGlobalConfig.RankVibration.Count - 1; curRateIndex = _tables.TbGlobalConfig.RankVibration.Count - 1;
int hapticType = _tables.TbGlobalConfig.RankVibration[curRateIndex]; int hapticType = _tables.TbGlobalConfig.RankVibration[curRateIndex];
@@ -824,14 +717,8 @@ public class FishingRewardPanel : BasePanel
var progress = 0f; var progress = 0f;
int cash = add / Magnification; int cash = add / Magnification;
yield return Awaiters.Seconds(waitTime1); yield return Awaiters.Seconds(waitTime1);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) yield break;
#endif
DOTween.To(() => progress, x => progress = x, 1, duration1).OnUpdate(() => DOTween.To(() => progress, x => progress = x, 1, duration1).OnUpdate(() =>
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
text_weight_num.text = LocalizationMgr.GetFormatTextValue("UI_FishingRewardPanel_101014", text_weight_num.text = LocalizationMgr.GetFormatTextValue("UI_FishingRewardPanel_101014",
ConvertTools.GetNumberString2((int)(_fishScore * progress))); ConvertTools.GetNumberString2((int)(_fishScore * progress)));
text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight * progress, true); text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight * progress, true);
@@ -842,9 +729,6 @@ public class FishingRewardPanel : BasePanel
.SetId("AddCash") .SetId("AddCash")
.OnComplete(() => .OnComplete(() =>
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight); text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight);
} }
); );
@@ -869,10 +753,7 @@ public class FishingRewardPanel : BasePanel
curRate = etor.Current.Key; curRate = etor.Current.Key;
} }
while (etor.MoveNext()); while (etor.MoveNext());
var rateObject=await Addressables.InstantiateAsync(curRate, icon_rate).Task; await Addressables.InstantiateAsync(curRate, icon_rate).Task;
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent() && rateObject!=null) Addressables.ReleaseInstance(rateObject);
#endif
} }
bool _newRecord; bool _newRecord;
@@ -1176,10 +1057,6 @@ public class FishingRewardPanel : BasePanel
protected override void OnDestroy() protected override void OnDestroy()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"reward panel destroyed");
#endif
//ConvertTools.SetFSBlur(false); //ConvertTools.SetFSBlur(false);
EnableFishingBlur(false); EnableFishingBlur(false);
GContext.Publish(new PlayNewItemFxEvent() { type = 2 }); GContext.Publish(new PlayNewItemFxEvent() { type = 2 });
@@ -1276,9 +1153,6 @@ public class FishingRewardPanel : BasePanel
if (!string.IsNullOrEmpty(fishingData.AudioRewardFish)) if (!string.IsNullOrEmpty(fishingData.AudioRewardFish))
{ {
NewItemAudio = await Addressables.LoadAssetAsync<AudioClip>(fishingData.AudioRewardFish).Task; NewItemAudio = await Addressables.LoadAssetAsync<AudioClip>(fishingData.AudioRewardFish).Task;
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) { if(NewItemAudio!=null) Addressables.Release(NewItemAudio); NewItemAudio=null; return; }
#endif
PlayNewItemAudio(); PlayNewItemAudio();
fishingData.AudioRewardFish = ""; fishingData.AudioRewardFish = "";
} }
@@ -1340,9 +1214,6 @@ public class FishingRewardPanel : BasePanel
await GContext.container.Resolve<SoundMgr>().PlayFishingSound(fishingData.curFishData.AudioRewardPanel); await GContext.container.Resolve<SoundMgr>().PlayFishingSound(fishingData.curFishData.AudioRewardPanel);
} }
await Awaiters.NextFrame; await Awaiters.NextFrame;
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
if (this != null && !string.IsNullOrEmpty(fishingData.curFishData.AudioRewardPanelLoop)) if (this != null && !string.IsNullOrEmpty(fishingData.curFishData.AudioRewardPanelLoop))
{ {
GContext.Publish(new EventBGMSound(fishingData.curFishData.AudioRewardPanelLoop)); GContext.Publish(new EventBGMSound(fishingData.curFishData.AudioRewardPanelLoop));
@@ -1365,10 +1236,6 @@ public class FishingRewardPanel : BasePanel
string fishName = "0"; string fishName = "0";
private void OnGUI() private void OnGUI()
{ {
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return;
#endif
int fontSize = 40; int fontSize = 40;
GUI.skin.textField.fontSize = fontSize; GUI.skin.textField.fontSize = fontSize;
GUI.skin.button.fontSize = fontSize; GUI.skin.button.fontSize = fontSize;

View File

@@ -12,7 +12,7 @@ namespace Assets.Scripts.UI.JigsawPuzzle
{ {
public class JigsawPuzzleManager:IDisposable public class JigsawPuzzleManager:IDisposable
{ {
readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve<game.PlayerDataStorage.IPlayerSaveStore>(); readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve<game.PlayerDataStorage.IPlayerSaveStore>();
public const string PriceImgName = "separate_icon_eventjigsawpuzzle_piece_"; public const string PriceImgName = "separate_icon_eventjigsawpuzzle_piece_";

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using asap.core; using asap.core;
using cfg; using cfg;
using GameCore; using GameCore;
@@ -12,32 +12,13 @@ namespace game
{ {
[asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; } [asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
private readonly EventPinataPackData _kvTarget;
private EventPinataPackDataManager(EventPinataPackData target) { _kvTarget=target; }
internal static EventPinataPackDataManager CreateForKVLoad()
{
var target=GContext.container.Resolve<EventPinataPackData>();
if(target==null) throw new System.InvalidOperationException("KV Pinata target is not registered.");
return new EventPinataPackDataManager(target);
}
private EventPinataPackData RequireKVTarget()
{
var target=GContext.container.Resolve<EventPinataPackData>();
if(target==null || (_kvTarget!=null && !ReferenceEquals(target,_kvTarget)))
throw new System.InvalidOperationException("KV Pinata target changed.");
return target;
}
internal static PlayerDataStorage.PlayerKVPinataState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => PlayerDataStorage.PlayerKVSaveSchema.DecodePinata(raw,errors);
internal void ResetForKVLoad()
{
var target=RequireKVTarget(); target.EventID=0; target.PinataMainID=0; target.PurchasedCount=0;
}
internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVPinataState state)
{
if(state==null) throw new System.ArgumentNullException(nameof(state));
var target=RequireKVTarget(); target.EventID=state.EventID; target.PinataMainID=state.PinataMainID; target.PurchasedCount=state.PurchasedCount;
}
#if UNITY_EDITOR #if UNITY_EDITOR
private bool m_IsCheckCondition = true; private bool m_IsCheckCondition = true;
#else #else

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