chore(m2): 保存功能验收及后续清理前检查点
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@ namespace game.PlayerDataStorage
|
||||
bool IsCurrent { get; }
|
||||
PlayerSaveReadResult Read(string key);
|
||||
PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true);
|
||||
Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default);
|
||||
Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default);
|
||||
Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default);
|
||||
Task<bool> SyncDataImmidiate(string key, string value);
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8cda3d2b981ada043af2162ca707d8bf
|
||||
guid: 04d2914963285d343b5a0e9503b59390
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@@ -98,7 +99,8 @@ namespace game.PlayerDataStorage
|
||||
if (mutation) return new PlayerKVResult(PlayerKVError.None, status);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
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 })
|
||||
{
|
||||
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);
|
||||
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>());
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,9 @@ using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// The lease-only capability remains source-compatible with the independent 4B1 regression host.
|
||||
// This separate capability supplies every dependency required by a complete controlled load.
|
||||
// Prebuild distinct transports with one cookie jar, then transfer each owned handler once.
|
||||
internal sealed class PlayerKVGameLoadCapability
|
||||
{
|
||||
internal PlayerKVIdentitySnapshot Identity { get; }
|
||||
internal PlayerKVSchemaSnapshot Snapshot { get; }
|
||||
internal Uri Api { get; }
|
||||
internal string CacheRoot { get; }
|
||||
internal int LoginObserveMs { get; }
|
||||
@@ -20,47 +17,59 @@ namespace game.PlayerDataStorage
|
||||
internal bool RequireExistingArchive { get; }
|
||||
internal Func<int,CancellationToken,Task> Delay { get; }
|
||||
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();
|
||||
CookieContainer cookies;
|
||||
HttpMessageHandler pendingKV,pendingLease;
|
||||
Exception cleanupFailure;
|
||||
readonly System.Collections.Generic.List<HttpMessageHandler> failedHandlers=new System.Collections.Generic.List<HttpMessageHandler>();
|
||||
bool claimed;
|
||||
private PlayerKVGameLoadCapability(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,Uri api,string cacheRoot,
|
||||
int loginMs,int quitMs,Func<int,CancellationToken,Task> delay,
|
||||
PlayerKVControlledLoadCapability auth,PlayerKVControlledLoadCapability kv,PlayerKVControlledLoadCapability lease,
|
||||
bool requireExistingArchive=false)
|
||||
private PlayerKVGameLoadCapability(Uri api, string cacheRoot,
|
||||
int loginMs, int quitMs, Func<int, CancellationToken, Task> delay,
|
||||
Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
|
||||
Func<CookieContainer, HttpMessageHandler> lease, bool requireExistingArchive = false, bool prepareOriginalComparison = false)
|
||||
{
|
||||
Identity=identity ?? throw new ArgumentNullException(nameof(identity)); Snapshot=snapshot ?? throw new ArgumentNullException(nameof(snapshot));
|
||||
if(api==null || !api.IsAbsoluteUri || (api.Scheme!="http" && api.Scheme!="https") || !string.IsNullOrEmpty(api.UserInfo) ||
|
||||
// Preserve the former wrappers' null-check order, diagnostic and configuration precedence.
|
||||
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) ||
|
||||
loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000) throw new ArgumentException("Controlled load configuration invalid.");
|
||||
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;
|
||||
RequireExistingArchive=requireExistingArchive;
|
||||
loginMs < 1000 || loginMs > 15000 || quitMs < 1000 || quitMs > 15000)
|
||||
{
|
||||
throw new ArgumentException("Controlled load configuration invalid.");
|
||||
}
|
||||
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,
|
||||
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv,
|
||||
Func<CookieContainer,HttpMessageHandler> lease)
|
||||
internal static PlayerKVGameLoadCapability CreateForGame(Uri api, string cacheRoot,
|
||||
Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
|
||||
Func<CookieContainer, HttpMessageHandler> lease, bool prepareOriginalComparison = false)
|
||||
{
|
||||
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,
|
||||
PlayerKVControlledLoadCapability.CreateForGame(auth),PlayerKVControlledLoadCapability.CreateForGame(kv),
|
||||
PlayerKVControlledLoadCapability.CreateForGame(lease),true);
|
||||
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForGame(capability.TakeLease);
|
||||
if (api == null || api.AbsoluteUri != "http://s101.local:18080/")
|
||||
{
|
||||
throw new ArgumentException("Formal KV endpoint mismatch.", nameof(api));
|
||||
}
|
||||
var capability = new PlayerKVGameLoadCapability(api, cacheRoot, 10000, 5000, null, auth, kv, lease, true, prepareOriginalComparison);
|
||||
capability.LeaseCapability = PlayerKVControlledLoadCapability.CreateForGame(capability.TakeLease);
|
||||
return capability;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVGameLoadCapability CreateForValidation(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,
|
||||
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv,
|
||||
Func<CookieContainer,HttpMessageHandler> lease,int loginMs=10000,int quitMs=5000,Func<int,CancellationToken,Task> delay=null)
|
||||
internal static PlayerKVGameLoadCapability CreateForValidation(Uri api, string cacheRoot,
|
||||
Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
|
||||
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,
|
||||
PlayerKVControlledLoadCapability.CreateForValidation(auth),PlayerKVControlledLoadCapability.CreateForValidation(kv),
|
||||
PlayerKVControlledLoadCapability.CreateForValidation(lease));
|
||||
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
|
||||
var capability = new PlayerKVGameLoadCapability(api, cacheRoot, loginMs, quitMs, delay, auth, kv, lease);
|
||||
capability.LeaseCapability = PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
|
||||
return capability;
|
||||
}
|
||||
#endif
|
||||
@@ -68,26 +77,48 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
lock(gate) { if(claimed) throw new InvalidOperationException("Controlled capability already used."); claimed=true; }
|
||||
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.");
|
||||
cookies=jar; HttpMessageHandler authentication=null;
|
||||
if (cookies != null)
|
||||
{
|
||||
throw new InvalidOperationException("Repeated authentication factory.");
|
||||
}
|
||||
cookies = jar;
|
||||
HttpMessageHandler authentication = null;
|
||||
try
|
||||
{
|
||||
// Validate all three returned handler graphs before the first authentication send.
|
||||
authentication=auth.CreateHandler(jar); pendingKV=kv.CreateHandler(jar); pendingLease=lease.CreateHandler(jar);
|
||||
if(ShareHandlerGraph(authentication,pendingKV,pendingLease))
|
||||
authentication = PlayerKVControlledLoadCapability.CreateHandler(auth, jar, LeaseCapability.IsFormal);
|
||||
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.");
|
||||
}
|
||||
return authentication;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { authentication?.Dispose(); } catch(Exception ex) { cleanupFailure=ex; failedHandlers.Add(authentication); }
|
||||
try { CloseUnused(); } catch { }
|
||||
try
|
||||
{
|
||||
authentication?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
cleanupFailure = ex;
|
||||
failedHandlers.Add(authentication);
|
||||
}
|
||||
try
|
||||
{
|
||||
CloseUnused();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35b5e1a67b2043b5aa3c34a67d2fe8f0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
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.
|
||||
internal sealed class PlayerKVPrepareEvidence
|
||||
{
|
||||
@@ -20,232 +42,415 @@ namespace game.PlayerDataStorage
|
||||
internal int? HttpStatusCode { get; }
|
||||
internal string ExceptionType { get; }
|
||||
internal string CleanupExceptionType { get; }
|
||||
|
||||
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;
|
||||
ExceptionType=value.ExceptionType; CleanupExceptionType=value.CleanupExceptionType; }
|
||||
{
|
||||
Error = value.Error;
|
||||
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 PlayerKVGameLoadState State { get; }
|
||||
internal PlayerKVGameLoadError Error { get; }
|
||||
internal PlayerKVGameLoadError OriginalError { get; }
|
||||
internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed;
|
||||
internal PlayerKVSchemaVerdict? SchemaVerdict { get; }
|
||||
internal PlayerKVPrepareEvidence Prepare { get; }
|
||||
internal long Epoch { get; }
|
||||
internal PlayerKVAccountDescriptor Account { get; }
|
||||
internal int ModuleCount { get; }
|
||||
internal int IntentCount { get; }
|
||||
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null,
|
||||
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;
|
||||
Account=error==PlayerKVGameLoadError.None && forGame ? account : null;
|
||||
State=error!=PlayerKVGameLoadError.None ? PlayerKVGameLoadState.Rejected : forGame ? PlayerKVGameLoadState.GameLoaded : PlayerKVGameLoadState.LoadedForValidation; }
|
||||
internal PlayerKVGameLoadState State
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal PlayerKVGameLoadError Error
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal PlayerKVGameLoadError OriginalError
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal bool CleanupFailed => Error == PlayerKVGameLoadError.CloseFailed;
|
||||
internal PlayerKVPrepareEvidence Prepare
|
||||
{
|
||||
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
|
||||
{
|
||||
readonly SemaphoreSlim loginGate=new SemaphoreSlim(1,1);
|
||||
readonly object closeGate=new object();
|
||||
readonly PlayerKVGameLoadTransaction transaction;
|
||||
readonly Func<bool> unstarted;
|
||||
readonly Action notify;
|
||||
readonly Func<int> intentCount;
|
||||
readonly Action<PlayerKVAccountLease> bind;
|
||||
readonly Func<Task> unbind;
|
||||
readonly PlayerKVShutdownRegistry registry;
|
||||
readonly int mainThread;
|
||||
readonly SynchronizationContext mainContext;
|
||||
PlayerKVLoginContext context;
|
||||
PlayerKVGameLoadGuard guard;
|
||||
PlayerKVGameLoadCapability capability;
|
||||
Task closing;
|
||||
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;
|
||||
private readonly SemaphoreSlim loginGate = new SemaphoreSlim(1, 1);
|
||||
private readonly object closeGate = new object ();
|
||||
private readonly Func<bool> unstarted;
|
||||
private readonly Action<PlayerKVAccountLease> bind;
|
||||
private readonly Func<Task> unbind;
|
||||
private readonly PlayerKVShutdownRegistry registry;
|
||||
private readonly int mainThread;
|
||||
private readonly SynchronizationContext mainContext;
|
||||
private PlayerKVLoginContext context;
|
||||
private PlayerKVGameLoadGuard guard;
|
||||
private PlayerKVGameLoadCapability capability;
|
||||
private Task closing;
|
||||
private CancellationTokenRegistration lifetimeRegistration;
|
||||
private long epoch;
|
||||
private PlayerKVGameSessionHandle gameHandle;
|
||||
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.notify=notify ?? throw new ArgumentNullException(nameof(notify));
|
||||
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.registry=registry ?? PlayerKVShutdownRegistry.Process;
|
||||
mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current;
|
||||
this.unstarted = unstarted ?? throw new ArgumentNullException(nameof(unstarted));
|
||||
this.bind = bind ?? throw new ArgumentNullException(nameof(bind));
|
||||
this.unbind = unbind ?? throw new ArgumentNullException(nameof(unbind));
|
||||
this.registry = registry ?? PlayerKVShutdownRegistry.Process;
|
||||
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,CancellationToken token=default,Func<bool> identityCurrent=null)
|
||||
=> LoadCoreAsync(id,next,token,identityCurrent,true);
|
||||
internal bool TryGetCurrentGameHandle(long expectedEpoch,out PlayerKVGameSessionHandle handle)
|
||||
|
||||
internal Task<PlayerKVGameLoadResult> LoadForGameAsync(string id, PlayerKVGameLoadCapability next,
|
||||
CancellationToken token = default, Func<bool> identityCurrent = null)
|
||||
{
|
||||
handle=null;
|
||||
PlayerKVLoginContext owner; PlayerKVGameSessionHandle published;
|
||||
lock(closeGate)
|
||||
return LoadCoreAsync(id, next, token, identityCurrent);
|
||||
}
|
||||
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;
|
||||
owner=context; published=gameHandle;
|
||||
if (closing != null || epoch != expectedEpoch || gameHandle == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
owner = context;
|
||||
published = gameHandle;
|
||||
}
|
||||
|
||||
// Never call the external identity predicate while holding a publication monitor.
|
||||
if(!owner.TryAcquireGameHandle(expectedEpoch,out var original) || !ReferenceEquals(original,published)) return false;
|
||||
lock(closeGate)
|
||||
if (!owner.TryAcquireGameHandle(expectedEpoch, out var original) || !ReferenceEquals(original, published))
|
||||
{
|
||||
if(closing!=null || epoch!=expectedEpoch || !ReferenceEquals(context,owner) || !ReferenceEquals(gameHandle,published)) return false;
|
||||
handle=published;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (closeGate)
|
||||
{
|
||||
if (closing != null || epoch != expectedEpoch || !ReferenceEquals(context, owner) || !ReferenceEquals(gameHandle, published))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
handle = published;
|
||||
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);
|
||||
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
|
||||
{
|
||||
// An earlier successful validation must also be explicitly revoked before replacing its account.
|
||||
if(context!=null) _=Revoke();
|
||||
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() || transaction.Poisoned) return Result(PlayerKVGameLoadError.Input);
|
||||
if(!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard)) return Result(PlayerKVGameLoadError.GateBusy);
|
||||
if (context != null)
|
||||
{
|
||||
_ = Revoke();
|
||||
}
|
||||
|
||||
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();
|
||||
lock(closeGate)
|
||||
lock (closeGate)
|
||||
{
|
||||
guard=enteredGuard; capability=next; closing=null; loaded=false; gameHandle=null;
|
||||
attempt=Interlocked.Increment(ref epoch);
|
||||
context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent()));
|
||||
guard = enteredGuard;
|
||||
capability = next;
|
||||
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 work=context.PrepareAsync(token);
|
||||
var cancelled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using(token.Register(()=>cancelled.TrySetResult(true)))
|
||||
|
||||
var owner = context;
|
||||
lifetimeRegistration = token.Register(() =>
|
||||
{
|
||||
if(await Task.WhenAny(work,cancelled.Task)!=work)
|
||||
return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
prepared=await work;
|
||||
}
|
||||
if(!prepared.Prepared) return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare,attempt,next,prepared,verdict);
|
||||
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)
|
||||
_ = Revoke(attempt);
|
||||
});
|
||||
var work = context.PrepareAsync(token);
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (token.Register(() => cancelled.TrySetResult(true)))
|
||||
{
|
||||
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();
|
||||
// Dispose serializes with Reject; its final count cannot change afterwards.
|
||||
if(enteredGuard.Violations==0) { gameHandle=original; published=true; }
|
||||
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); }
|
||||
finally { loginGate.Release(); }
|
||||
}
|
||||
async Task<bool> EnterGate(PlayerKVGameLoadCapability next,CancellationToken token)
|
||||
{
|
||||
using(var cancellation=CancellationTokenSource.CreateLinkedTokenSource(token))
|
||||
catch
|
||||
{
|
||||
var wait=loginGate.WaitAsync(cancellation.Token); var deadline=next.Delay(next.LoginObserveMs,cancellation.Token);
|
||||
bool acquired=false,accepted=false;
|
||||
return await Reject(PlayerKVGameLoadError.Apply, attempt, next, prepared);
|
||||
}
|
||||
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
|
||||
{
|
||||
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();
|
||||
try { await wait; acquired=true; } catch(OperationCanceledException) { }
|
||||
try
|
||||
{
|
||||
await wait;
|
||||
acquired = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch(OperationCanceledException) { return false; }
|
||||
finally
|
||||
{
|
||||
cancellation.Cancel();
|
||||
// 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(closing!=null) return closing;
|
||||
loaded=false; 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 reset is queued.
|
||||
var drain=owner?.CloseAsync() ?? Task.CompletedTask;
|
||||
var cleared=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (expectedEpoch.HasValue && expectedEpoch.Value != Interlocked.Read(ref epoch))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (closing != null)
|
||||
{
|
||||
return closing;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
try
|
||||
{
|
||||
var detached=unbind();
|
||||
if(!transaction.Clear()) throw new InvalidOperationException("Controlled container is poisoned.");
|
||||
CompleteClear(detached,cleared);
|
||||
var detached = unbind();
|
||||
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();
|
||||
else if(mainContext!=null) 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);
|
||||
|
||||
if (Thread.CurrentThread.ManagedThreadId == mainThread)
|
||||
{
|
||||
Clear();
|
||||
}
|
||||
else if (mainContext != null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
static async void CompleteClear(Task detached,TaskCompletionSource<bool> cleared)
|
||||
{ try { await detached; cleared.TrySetResult(true); } catch(Exception ex) { cleared.TrySetException(ex); } }
|
||||
static async Task FinishClose(Task drain,Task clear,PlayerKVGameLoadGuard leaseGuard,PlayerKVGameLoadCapability source,TaskCompletionSource<bool> completion)
|
||||
|
||||
static async void CompleteClear(Task detached, TaskCompletionSource<bool> cleared)
|
||||
{
|
||||
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();
|
||||
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,verdict:verdict,prepare:prepare);
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(drain, clear).ConfigureAwait(false);
|
||||
source?.CloseUnused();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1ea0dd4977649169863e3c917802188
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -87,8 +88,13 @@ namespace game.PlayerDataStorage
|
||||
public PlayerKVGameSessionBridge() : this(jar => new HttpClientHandler
|
||||
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
|
||||
|
||||
internal PlayerKVGameSessionBridge(Func<CookieContainer, HttpMessageHandler> handlerFactory)
|
||||
{ this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); }
|
||||
private readonly bool prepareOriginalComparison;
|
||||
|
||||
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,
|
||||
string playerId, Uri api, long epoch, Func<long, bool> isCurrent, CancellationToken lifetimeToken = default)
|
||||
@@ -145,7 +151,7 @@ namespace game.PlayerDataStorage
|
||||
active = slot;
|
||||
}
|
||||
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);
|
||||
if (!opened.Success)
|
||||
{
|
||||
@@ -261,9 +267,49 @@ namespace game.PlayerDataStorage
|
||||
? new PlayerKVLocalReadResult(PlayerKVLocalReadError.StaleOrClosing, OwnerInstanceId, PlayerId, Epoch)
|
||||
: Owner.ReadLocalSnapshot();
|
||||
|
||||
public PlayerKVEditResult Update(string key, string value, bool remoteStore = true) =>
|
||||
slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore);
|
||||
public PlayerKVEditResult Update(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
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 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
|
||||
? Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status))
|
||||
: Owner.FlushAsync(waitToken);
|
||||
@@ -273,4 +319,4 @@ namespace game.PlayerDataStorage
|
||||
static PlayerKVEditResult BlockedEdit() =>
|
||||
new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7803da4435ea45c28007ff0198c676fb
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6215961788794950aff0709dbb499793
|
||||
@@ -58,7 +58,7 @@ namespace game.PlayerDataStorage
|
||||
internal PlayerKVAccountDescriptor AccountDescriptor =>
|
||||
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)
|
||||
{
|
||||
gameHandle = null;
|
||||
@@ -351,7 +351,13 @@ namespace game.PlayerDataStorage
|
||||
|
||||
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 node = handler;
|
||||
while (node != null)
|
||||
@@ -359,7 +365,9 @@ namespace game.PlayerDataStorage
|
||||
// Disposing a cyclic DelegatingHandler graph recursively would overflow the stack.
|
||||
// It never becomes a lease; the rejecting factory retains responsibility for that graph.
|
||||
if (!seen.Add(node))
|
||||
{
|
||||
throw new InvalidOperationException("Cyclic transport is not a validation handler.");
|
||||
}
|
||||
if (!formal && (node is HttpClientHandler ||
|
||||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler"))
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
@@ -15,16 +16,23 @@ namespace game.PlayerDataStorage
|
||||
public int? StatusCode { get; }
|
||||
// Only a successful Get has Data. Failure must never look like an empty save.
|
||||
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.
|
||||
// This is conservative, including HTTP failures. Do not blindly replay the write.
|
||||
public bool MayHaveCommitted { get; }
|
||||
|
||||
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;
|
||||
StatusCode = statusCode;
|
||||
Data = data == null ? null : new ReadOnlyDictionary<string, string>(data);
|
||||
UpdatedAtUtc = updatedAtUtc == null ? null : new ReadOnlyDictionary<string, DateTime>(updatedAtUtc);
|
||||
MayHaveCommitted = mayHaveCommitted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ namespace game.PlayerDataStorage
|
||||
return new PlayerKVRuntimeSelection(false, null);
|
||||
if (backend != "kv")
|
||||
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))
|
||||
throw new ArgumentException("KV API address does not match the reviewed endpoint.", nameof(api));
|
||||
return new PlayerKVRuntimeSelection(true, new Uri(FormalApiUrl));
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 860b04f8a2a64eb8acf77e96695f6c54
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c49515cb90840a189772d23ad69fd2b
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
@@ -76,7 +77,7 @@ namespace game.PlayerDataStorage
|
||||
internal static async Task<PlayerKVSessionOpenResult> CreateAsync(S101AccountSession session, string cacheRoot,
|
||||
string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func<long, bool> isCurrent,
|
||||
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 ||
|
||||
!long.TryParse(expectedPlayerId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
|
||||
@@ -112,7 +113,7 @@ namespace game.PlayerDataStorage
|
||||
using (var lease = owner.TryEnter())
|
||||
{
|
||||
if (lease == null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled);
|
||||
read = await owner.coordinator.InitializeAsync();
|
||||
read = await owner.coordinator.InitializeAsync(prepareOriginalComparison);
|
||||
}
|
||||
if (!read.Success)
|
||||
return await owner.FailCreate(read.Error == PlayerKVError.Unauthorized
|
||||
@@ -166,9 +167,49 @@ namespace game.PlayerDataStorage
|
||||
PlayerKVLocalReadResult ReadFailure(PlayerKVLocalReadError error) =>
|
||||
new PlayerKVLocalReadResult(error, ownerId, PlayerId, Epoch);
|
||||
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())
|
||||
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)
|
||||
@@ -177,6 +218,30 @@ namespace game.PlayerDataStorage
|
||||
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)
|
||||
{
|
||||
var lease = TryEnter();
|
||||
@@ -308,7 +373,10 @@ namespace game.PlayerDataStorage
|
||||
|
||||
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) &&
|
||||
session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri;
|
||||
}
|
||||
|
||||
@@ -42,11 +42,16 @@ namespace game.PlayerDataStorage
|
||||
public int Superseded { get; }
|
||||
public int Blocked { get; }
|
||||
public int Unknown { get; }
|
||||
public int Buffered
|
||||
{
|
||||
get;
|
||||
}
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVSyncStatus CacheStatus { get; }
|
||||
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;
|
||||
TransportError = transportError; CacheStatus = cacheStatus;
|
||||
}
|
||||
@@ -74,7 +79,9 @@ namespace game.PlayerDataStorage
|
||||
readonly long epoch;
|
||||
readonly Func<bool> isOwnerCurrent;
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVResult> InitializeAsync()
|
||||
internal async Task<PlayerKVResult> InitializeAsync(bool prepareOriginalComparison = false)
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
try
|
||||
{
|
||||
if (!Current()) return CancelledResult();
|
||||
if (state.HasPendingComparison)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Protocol);
|
||||
}
|
||||
var result = await client.GetAsync(null, ownerToken);
|
||||
if (!Current()) return CancelledResult();
|
||||
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);
|
||||
}
|
||||
state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized);
|
||||
@@ -103,127 +114,489 @@ namespace game.PlayerDataStorage
|
||||
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)
|
||||
{
|
||||
SupersedeUnsentForKey(key, result.AttemptSequence);
|
||||
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
|
||||
if (!Current())
|
||||
{
|
||||
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)
|
||||
{
|
||||
var result = state.Delete(key);
|
||||
if (!result.Success) return new PlayerKVEditResult(result);
|
||||
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
|
||||
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));
|
||||
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)
|
||||
{
|
||||
var next = state.Inspect().NextSequence;
|
||||
var watermark = next <= 1 ? 0 : next - 1;
|
||||
lock (ticketGate)
|
||||
{
|
||||
targetSequences = tickets.Values.Where(record => record.Ticket.Sequence <= watermark && !record.ClaimedByFlush)
|
||||
.Select(record => { record.ClaimedByFlush = true; return record.Ticket.Sequence; }).ToArray();
|
||||
var snapshot = state.Inspect();
|
||||
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;
|
||||
var targetSet = new HashSet<long>(targetSequences ?? Array.Empty<long>());
|
||||
var recoveredConfirmed = 0;
|
||||
var recoveredBlocked = 0;
|
||||
var recoveredUnknown = 0;
|
||||
return Unsent(snapshot).Concat(snapshot.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
|
||||
.Concat(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items))
|
||||
.Concat(snapshot.ActiveBatch == null ? Enumerable.Empty<PlayerKVChange>() : snapshot.ActiveBatch.Items)
|
||||
.Concat(snapshot.UnknownBatch == null ? Enumerable.Empty<PlayerKVChange>() : snapshot.UnknownBatch.Items);
|
||||
}
|
||||
|
||||
// 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;
|
||||
try
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
acquired = true;
|
||||
while (Current())
|
||||
var sent = 0;
|
||||
while (Current() && sent < maximumBatches)
|
||||
{
|
||||
MarkSuperseded(watermark);
|
||||
var begin = state.BeginBatch(watermark, out var batch);
|
||||
PlayerKVStateResult begin;
|
||||
PlayerKVBatch batch;
|
||||
lock (ticketGate)
|
||||
{
|
||||
begin = state.BeginBatch(watermark, legacyOnly, out batch);
|
||||
if (begin.Success && batch != null)
|
||||
{
|
||||
MarkDispatched(batch);
|
||||
}
|
||||
}
|
||||
if (!begin.Success)
|
||||
{
|
||||
recoveredBlocked += state.Inspect().Pending.Values.Count(item =>
|
||||
item.Sequence <= watermark && !targetSet.Contains(item.Sequence));
|
||||
MarkEligibleBlocked(watermark, PlayerKVError.None);
|
||||
MarkExplicitBlocked(watermark, legacyOnly, PlayerKVError.None, observed);
|
||||
break;
|
||||
}
|
||||
if (batch == null) break;
|
||||
MarkDispatched(batch);
|
||||
PlayerKVResult result;
|
||||
if (batch.IsDelete)
|
||||
result = await client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken);
|
||||
else
|
||||
result = await client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value,
|
||||
StringComparer.Ordinal), ownerToken);
|
||||
|
||||
if (batch == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
sent++;
|
||||
var result = await SendAsync(batch);
|
||||
if (!Current() && result.Success)
|
||||
{
|
||||
result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true);
|
||||
|
||||
}
|
||||
if (result.Success)
|
||||
{
|
||||
var confirmed = state.ConfirmBatch(batch.Id);
|
||||
var outcome = confirmed.Success ? PlayerKVTicketStatus.Confirmed : PlayerKVTicketStatus.Unknown;
|
||||
RecordBatch(batch, outcome, PlayerKVError.None, observed);
|
||||
if (!confirmed.Success)
|
||||
{
|
||||
CompleteBatch(batch, PlayerKVTicketStatus.Unknown, PlayerKVError.None);
|
||||
recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
finalError = PlayerKVError.None;
|
||||
break;
|
||||
}
|
||||
CompleteBatch(batch, PlayerKVTicketStatus.Confirmed, PlayerKVError.None);
|
||||
recoveredConfirmed += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
continue;
|
||||
}
|
||||
|
||||
finalError = result.Error;
|
||||
var failed = state.FailBatch(batch.Id, result.MayHaveCommitted,
|
||||
result.Error == PlayerKVError.Unauthorized);
|
||||
var status = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
|
||||
error = result.Error;
|
||||
state.FailBatch(batch.Id, result.MayHaveCommitted, result.Error == PlayerKVError.Unauthorized);
|
||||
var failedStatus = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
|
||||
ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked;
|
||||
CompleteBatch(batch, status, result.Error);
|
||||
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);
|
||||
RecordBatch(batch, failedStatus, result.Error, observed);
|
||||
break;
|
||||
}
|
||||
if (!Current())
|
||||
{
|
||||
if (finalError == PlayerKVError.None) finalError = PlayerKVError.Cancelled;
|
||||
MarkEligibleOnClose(watermark);
|
||||
error = PlayerKVError.Cancelled;
|
||||
CloseTickets();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
finalError = PlayerKVError.Cancelled;
|
||||
MarkEligibleOnClose(watermark);
|
||||
error = PlayerKVError.Cancelled;
|
||||
CloseTickets();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
finalError = PlayerKVError.Protocol;
|
||||
MarkEligibleBlocked(watermark, finalError);
|
||||
error = PlayerKVError.Protocol;
|
||||
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)
|
||||
@@ -269,60 +642,55 @@ namespace game.PlayerDataStorage
|
||||
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) =>
|
||||
record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status));
|
||||
|
||||
PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error,
|
||||
int recoveredConfirmed, int recoveredBlocked, int recoveredUnknown)
|
||||
private PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error,
|
||||
Dictionary<long, PlayerKVTicketStatus> observed = null)
|
||||
{
|
||||
var counts = new Dictionary<PlayerKVTicketStatus, int>();
|
||||
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;
|
||||
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;
|
||||
unknown.UnionWith(snapshot.UnknownBatch.Items.Select(item => item.Sequence));
|
||||
}
|
||||
Func<PlayerKVTicketStatus, int> countOf = status => counts.TryGetValue(status, out var count) ? count : 0;
|
||||
return new PlayerKVFlushResult(countOf(PlayerKVTicketStatus.Confirmed) + recoveredConfirmed,
|
||||
countOf(PlayerKVTicketStatus.Superseded),
|
||||
countOf(PlayerKVTicketStatus.Blocked) + countOf(PlayerKVTicketStatus.Cancelled) + recoveredBlocked,
|
||||
countOf(PlayerKVTicketStatus.Unknown) + recoveredUnknown, error, state.Status);
|
||||
var outstanding = new HashSet<long>(Outstanding(snapshot).Select(item => item.Sequence));
|
||||
foreach (var sequence in targetSequences.Distinct())
|
||||
{
|
||||
PlayerKVTicketStatus outcome;
|
||||
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();
|
||||
|
||||
@@ -65,8 +65,16 @@ namespace game.PlayerDataStorage
|
||||
public string Id { get; }
|
||||
public IReadOnlyList<PlayerKVChange> Items { get; }
|
||||
public bool IsDelete => !Items[0].Target.Exists;
|
||||
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items)
|
||||
{ Id = id; Items = Array.AsReadOnly(items.ToArray()); }
|
||||
public bool IsOrdinary
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items, bool ordinary = false)
|
||||
{
|
||||
Id = id;
|
||||
Items = Array.AsReadOnly(items.ToArray());
|
||||
IsOrdinary = ordinary;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlayerKVDiagnosticSnapshot
|
||||
@@ -81,6 +89,34 @@ namespace game.PlayerDataStorage
|
||||
public IReadOnlyDictionary<string, PlayerKVValue> ConflictObservations { get; }
|
||||
public PlayerKVBatch ActiveBatch { 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)
|
||||
{
|
||||
Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline;
|
||||
@@ -88,6 +124,13 @@ namespace game.PlayerDataStorage
|
||||
Baseline = ReadOnly(source.Baseline); LocalOnly = ReadOnly(source.LocalOnly);
|
||||
Pending = ReadOnly(source.Pending); ConflictObservations = ReadOnly(source.Conflicts);
|
||||
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) =>
|
||||
new ReadOnlyDictionary<string, T>(new Dictionary<string, T>(source, StringComparer.Ordinal));
|
||||
@@ -118,38 +161,111 @@ namespace game.PlayerDataStorage
|
||||
internal long Revision, NextSequence = 1;
|
||||
internal bool HasBaseline, AuthenticationInvalid;
|
||||
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, PlayerKVChange> Pending = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, string> LocalData = new Dictionary<string, string>(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<long, PlayerKVChange> RemoteOnlyWrites = new Dictionary<long, PlayerKVChange>();
|
||||
internal PlayerKVBatch Active, Unknown;
|
||||
internal KVSnapshot Clone() => new KVSnapshot {
|
||||
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid,
|
||||
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal), LocalOnly = new Dictionary<string, string>(LocalOnly, StringComparer.Ordinal),
|
||||
Pending = new Dictionary<string, PlayerKVChange>(Pending, StringComparer.Ordinal), Conflicts = new Dictionary<string, PlayerKVValue>(Conflicts, StringComparer.Ordinal),
|
||||
Active = Active, Unknown = Unknown
|
||||
};
|
||||
|
||||
// Compatibility diagnostics only. A key projection loses cross-bucket sequence history.
|
||||
internal Dictionary<string, PlayerKVChange> Pending => Unsent().GroupBy(item => item.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.OrderBy(item => item.Sequence).Last(), StringComparer.Ordinal);
|
||||
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 string Encode(KVIdentity identity)
|
||||
{
|
||||
var pending = new JArray(Pending.Values.OrderBy(p => p.Sequence).Select(ChangeJson));
|
||||
var conflicts = new JObject();
|
||||
foreach (var pair in Conflicts) conflicts.Add(pair.Key, ValueJson(pair.Value));
|
||||
return new JObject {
|
||||
["formatVersion"] = 1, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId,
|
||||
foreach (var pair in Conflicts)
|
||||
{
|
||||
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,
|
||||
["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline),
|
||||
["localOnly"] = JObject.FromObject(LocalOnly), ["pending"] = pending, ["conflictObservations"] = conflicts,
|
||||
["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown)
|
||||
["localData"] = JObject.FromObject(LocalData), ["localUpdateTicks"] = JObject.FromObject(LocalUpdateTicks),
|
||||
["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);
|
||||
}
|
||||
static JObject ValueJson(PlayerKVValue value) => new JObject { ["exists"] = value.Exists, ["value"] = value.Value };
|
||||
static JObject ChangeJson(PlayerKVChange item) => new JObject {
|
||||
["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target)
|
||||
};
|
||||
static JToken BatchJson(PlayerKVBatch batch) => batch == null ? JValue.CreateNull() : (JToken)new JObject {
|
||||
["id"] = batch.Id, ["items"] = new JArray(batch.Items.Select(ChangeJson))
|
||||
};
|
||||
private static JArray ChangesJson(IEnumerable<PlayerKVChange> values)
|
||||
{
|
||||
return new JArray(values.OrderBy(item => item.Sequence).Select(ChangeJson));
|
||||
}
|
||||
private static JObject ValueJson(PlayerKVValue value)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -157,48 +273,268 @@ namespace game.PlayerDataStorage
|
||||
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
|
||||
{
|
||||
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 result = new KVSnapshot {
|
||||
var version = Integer(root, "formatVersion");
|
||||
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"),
|
||||
HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"),
|
||||
Baseline = Strings(Field(root, "baseline", JTokenType.Object)), LocalOnly = Strings(Field(root, "localOnly", JTokenType.Object)),
|
||||
Active = Batch(root, "activeBatch"), Unknown = Batch(root, "unknownBatch")
|
||||
Baseline = Strings(Field(root, "baseline", JTokenType.Object)),
|
||||
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())
|
||||
{
|
||||
if (!ValidKey(property.Name)) throw Invalid();
|
||||
if (!ValidKey(property.Name))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
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();
|
||||
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()
|
||||
{
|
||||
if (Revision < 1 || NextSequence < 1 || (Active != null && Unknown != null) ||
|
||||
(!HasBaseline && (Baseline.Count != 0 || LocalOnly.Count != 0 || Pending.Count != 0 || Active != null || Unknown != null))) throw Invalid();
|
||||
var sequences = new Dictionary<long, string>();
|
||||
foreach (var item in Pending.Values)
|
||||
if (Revision < 1 || NextSequence < 1 || OrdinaryBuffers.Count != 5 ||
|
||||
(Active != null && Unknown != null) || (Unknown != null && Unknown.IsOrdinary) ||
|
||||
OrdinaryBuffers.Any(bucket => bucket.Count > 10 || bucket.Values.Any(item => !item.Target.Exists)) ||
|
||||
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();
|
||||
sequences.Add(item.Sequence, item.Key);
|
||||
throw Invalid();
|
||||
}
|
||||
foreach (var key in Conflicts.Keys) if (!Pending.ContainsKey(key)) throw Invalid();
|
||||
var batch = Active ?? Unknown;
|
||||
if (batch == null) return;
|
||||
foreach (var item in batch.Items)
|
||||
var sequences = new Dictionary<long, PlayerKVChange>();
|
||||
void Add(PlayerKVChange item, bool sourceReference = false)
|
||||
{
|
||||
if (item.Sequence < 1 || item.Sequence >= NextSequence || !Pending.TryGetValue(item.Key, out var latest) || latest.Sequence < item.Sequence ||
|
||||
(sequences.TryGetValue(item.Sequence, out var key) && key != item.Key) ||
|
||||
!latest.BaseValue.Equals(item.BaseValue) ||
|
||||
(latest.Sequence == item.Sequence && !latest.Target.Equals(item.Target))) throw Invalid();
|
||||
if (!ValidKey(item.Key) || item.Sequence < 1 || item.Sequence >= NextSequence)
|
||||
{
|
||||
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)
|
||||
@@ -223,7 +559,7 @@ namespace game.PlayerDataStorage
|
||||
var value = Field(item, "value", exists ? JTokenType.String : JTokenType.Null);
|
||||
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];
|
||||
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 ||
|
||||
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();
|
||||
return new PlayerKVBatch(id, items);
|
||||
return new PlayerKVBatch(id, items, withKind && Boolean(node, "ordinary"));
|
||||
}
|
||||
static JToken Field(JToken parent, string name, JTokenType type)
|
||||
{
|
||||
|
||||
@@ -13,10 +13,38 @@ namespace game.PlayerDataStorage
|
||||
/// </summary>
|
||||
public sealed class PlayerKVSyncState : IDisposable
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly PlayerKVCacheStore store;
|
||||
KVSnapshot snapshot;
|
||||
PlayerKVSyncStatus status;
|
||||
private readonly object gate = new object();
|
||||
private readonly PlayerKVCacheStore store;
|
||||
private KVSnapshot snapshot;
|
||||
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 string ApiBaseUri { get; }
|
||||
public string PlayerId { get; }
|
||||
@@ -24,12 +52,26 @@ namespace game.PlayerDataStorage
|
||||
PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot 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
|
||||
// successful Get before Ready; this never clears a persisted unknown batch.
|
||||
snapshot.AuthenticationInvalid = false;
|
||||
// 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; }
|
||||
status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded;
|
||||
if (snapshot.Active != null)
|
||||
{
|
||||
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);
|
||||
@@ -50,39 +92,307 @@ namespace game.PlayerDataStorage
|
||||
lock (gate)
|
||||
{
|
||||
if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready.");
|
||||
var result = new Dictionary<string, string>(snapshot.Baseline, StringComparer.Ordinal);
|
||||
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);
|
||||
return new ReadOnlyDictionary<string, string>(ReadView());
|
||||
}
|
||||
}
|
||||
|
||||
// The caller must supply a successful complete Get. Failed reads must use RecordReadFailure.
|
||||
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)
|
||||
{
|
||||
if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (remote == null || remote.Any(p => !KVSnapshot.ValidKey(p.Key) || p.Value == null)) return Invalid();
|
||||
if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid || comparisonLocal != null)
|
||||
{
|
||||
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();
|
||||
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);
|
||||
}
|
||||
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);
|
||||
if (observed.Equals(item.Target)) copy.Pending.Remove(item.Key);
|
||||
else if (!observed.Equals(item.BaseValue)) copy.Conflicts[item.Key] = observed;
|
||||
if (!observed.Equals(item.Target) && !observed.Equals(item.BaseValue))
|
||||
{
|
||||
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;
|
||||
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)
|
||||
{
|
||||
lock (gate)
|
||||
@@ -98,42 +408,177 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
public PlayerKVStateResult Update(string key, string value, bool remoteStore = 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)
|
||||
{
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (!KVSnapshot.ValidKey(key) || target == null || snapshot.NextSequence == long.MaxValue) return Invalid();
|
||||
var copy = snapshot.Clone();
|
||||
var sequence = copy.NextSequence++;
|
||||
if (remote)
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
|
||||
{
|
||||
var baseline = copy.Pending.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key);
|
||||
copy.Pending[key] = new PlayerKVChange(key, sequence, baseline, target);
|
||||
copy.LocalOnly.Remove(key);
|
||||
return Blocked();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
batch = null;
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (throughSequence < 0) return Invalid();
|
||||
var ordered = snapshot.Pending.Values.Where(p => p.Sequence <= throughSequence).OrderBy(p => p.Sequence).ToArray();
|
||||
if (ordered.Length == 0) return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (snapshot.OrdinaryBuffers[0].Count == 0)
|
||||
{
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -142,35 +587,204 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
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();
|
||||
foreach (var item in copy.Active.Items)
|
||||
{
|
||||
Apply(copy.Baseline, item.Key, item.Target);
|
||||
var current = copy.Pending[item.Key];
|
||||
if (current.Sequence == item.Sequence) copy.Pending.Remove(item.Key);
|
||||
else copy.Pending[item.Key] = current.WithBase(item.Target);
|
||||
if (!copy.Active.IsOrdinary)
|
||||
{
|
||||
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;
|
||||
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)
|
||||
{
|
||||
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 ordinary = copy.Active.IsOrdinary;
|
||||
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;
|
||||
return Commit(copy, mayHaveCommitted ? PlayerKVSyncStatus.UnknownOutcome :
|
||||
copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.ReadFailed);
|
||||
var next = copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : ordinary ? PlayerKVSyncStatus.Ready : 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;
|
||||
PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0)
|
||||
{
|
||||
@@ -196,6 +810,7 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
if (status == PlayerKVSyncStatus.Closed) return;
|
||||
status = PlayerKVSyncStatus.Closed;
|
||||
ClearComparison();
|
||||
store.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -8,11 +8,10 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
public sealed class KVPlayerSaveStore : IPlayerSaveStore, IDisposable
|
||||
{
|
||||
PlayerKVGameSessionHandle handle;
|
||||
CancellationToken lifetime;
|
||||
bool bound, disposed;
|
||||
readonly object receiptsGate = new object();
|
||||
readonly Dictionary<string, PlayerKVSyncTicket> pendingReceipts = new Dictionary<string, PlayerKVSyncTicket>(StringComparer.Ordinal);
|
||||
private PlayerKVGameSessionHandle handle;
|
||||
private CancellationToken lifetime;
|
||||
private bool bound, disposed;
|
||||
private readonly object receiptsGate = new object();
|
||||
public bool IsCurrent => bound && !disposed && !lifetime.IsCancellationRequested && handle.Status != PlayerKVSyncStatus.Closed;
|
||||
|
||||
internal void Bind(PlayerKVGameSessionHandle original, CancellationToken loginLifetime)
|
||||
@@ -27,6 +26,23 @@ namespace game.PlayerDataStorage
|
||||
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)
|
||||
{
|
||||
if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady");
|
||||
@@ -45,94 +61,92 @@ namespace game.PlayerDataStorage
|
||||
return rejected;
|
||||
}
|
||||
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)) :
|
||||
new PlayerSaveWriteResult(result.StateResult.Error.ToString());
|
||||
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();
|
||||
var write = Write(key, value);
|
||||
if (!write.Accepted) return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, write.Error);
|
||||
return await ConfirmAsync(write.Receipt, token);
|
||||
}
|
||||
|
||||
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))
|
||||
PlayerKVGameSessionHandle original;
|
||||
CancellationToken originalLifetime;
|
||||
Task<bool> save;
|
||||
lock (receiptsGate)
|
||||
{
|
||||
try
|
||||
if (!IsCurrent)
|
||||
{
|
||||
await KVMgr.FlushKVAsync(handle, wait.Token);
|
||||
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());
|
||||
return false;
|
||||
}
|
||||
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();
|
||||
PlayerKVSyncTicket[] captured;
|
||||
lock (receiptsGate) captured = pendingReceipts.Values.ToArray();
|
||||
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
|
||||
PlayerKVGameSessionHandle original;
|
||||
CancellationToken originalLifetime;
|
||||
Task<bool> save;
|
||||
lock (receiptsGate)
|
||||
{
|
||||
try
|
||||
if (!IsCurrent || values == null || values.Count == 0)
|
||||
{
|
||||
var result = await KVMgr.FlushKVAsync(handle, wait.Token);
|
||||
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);
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
void ForgetConfirmed(PlayerKVSyncTicket receipt)
|
||||
internal Task<PlayerKVFlushResult> SyncDataAsync()
|
||||
{
|
||||
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 Task<bool> SyncDataImmidiate(string key, string value)
|
||||
{
|
||||
return manager.SyncDataImmidiate(key, value);
|
||||
}
|
||||
|
||||
readonly PlayFabMgr manager;
|
||||
readonly CancellationToken lifetime;
|
||||
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); }
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user