refactor(kv): 整理存档接线并清理迁移冗余实现
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4930c4a417ba4c908416717d26695848
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,224 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.Account
|
||||
{
|
||||
public enum AccountLoginError { None, Validation, Http, Rejected, Protocol, Network, Timeout, Cancelled }
|
||||
|
||||
public sealed class AccountLoginResult
|
||||
{
|
||||
public bool Success => Error == AccountLoginError.None;
|
||||
public AccountLoginError Error { get; }
|
||||
public int? StatusCode { get; }
|
||||
internal AccountLoginResult(AccountLoginError error, int? status = null)
|
||||
{ Error = error; StatusCode = status; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Tuyou-ID authentication, independent of gameplay and PlayFab storage.
|
||||
/// Each login owns a fresh cookie jar; dispose the session on logout.
|
||||
/// </summary>
|
||||
public sealed class S101AccountSession : IDisposable
|
||||
{
|
||||
public const string DefaultApiUrl = "http://s101.local:18080/";
|
||||
readonly object gate = new object();
|
||||
readonly Uri api;
|
||||
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
HttpClient http;
|
||||
int generation;
|
||||
bool disposed;
|
||||
|
||||
public bool IsAuthenticated { get; private set; }
|
||||
public string PlayerId { get; private set; }
|
||||
public uint InternalId { get; private set; }
|
||||
public DateTime CreateTimeUtc { get; private set; }
|
||||
public DateTime ServerTimeUtc { get; private set; }
|
||||
public bool NewlyCreated { get; private set; }
|
||||
public CookieContainer Cookies { get; private set; }
|
||||
public Uri ApiBaseUri => api;
|
||||
|
||||
public S101AccountSession() : this(new Uri(DefaultApiUrl)) { }
|
||||
|
||||
// The factory is optional for transport tests. Returned handlers are owned by this session.
|
||||
public S101AccountSession(Uri api, Func<CookieContainer, HttpMessageHandler> handlerFactory = null)
|
||||
{
|
||||
if (api == null || !api.IsAbsoluteUri ||
|
||||
(api.Scheme != "http" && api.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) ||
|
||||
!string.IsNullOrEmpty(api.UserInfo))
|
||||
throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(api));
|
||||
this.api = new Uri(api.AbsoluteUri.TrimEnd('/') + "/");
|
||||
this.handlerFactory = handlerFactory ?? (jar => new HttpClientHandler
|
||||
{
|
||||
CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<AccountLoginResult> LoginAsync(string tuyooId, CancellationToken token = default)
|
||||
{
|
||||
HttpClient client;
|
||||
CookieContainer cookies;
|
||||
int requestGeneration;
|
||||
string id;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return Result(AccountLoginError.Cancelled);
|
||||
generation++;
|
||||
ClearIdentity();
|
||||
http?.Dispose();
|
||||
http = null;
|
||||
Cookies = null;
|
||||
if (string.IsNullOrEmpty(tuyooId) ||
|
||||
!long.TryParse(tuyooId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
|
||||
return Result(AccountLoginError.Validation);
|
||||
if (token.IsCancellationRequested) return Result(AccountLoginError.Cancelled);
|
||||
id = numericId.ToString(CultureInfo.InvariantCulture);
|
||||
cookies = new CookieContainer();
|
||||
client = new HttpClient(handlerFactory(cookies)) { Timeout = TimeSpan.FromSeconds(15) };
|
||||
http = client;
|
||||
Cookies = cookies;
|
||||
requestGeneration = generation;
|
||||
}
|
||||
|
||||
int? status = null;
|
||||
try
|
||||
{
|
||||
using (var request = Request("account/Login", new JObject { ["playerId"] = id }))
|
||||
using (var response = await client.SendAsync(request, token))
|
||||
{
|
||||
status = (int)response.StatusCode;
|
||||
if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status);
|
||||
var body = Parse(await response.Content.ReadAsStringAsync());
|
||||
if (body["state"]?.Type != JTokenType.Integer) return Result(AccountLoginError.Protocol, status);
|
||||
if (body["state"].Value<long>() != 0) return Result(AccountLoginError.Rejected, status);
|
||||
if (body["internalId"]?.Type != JTokenType.Integer ||
|
||||
!uint.TryParse(body["internalId"].ToString(), out var internalId) || internalId == 0 ||
|
||||
body["newCreate"]?.Type != JTokenType.Boolean ||
|
||||
!ReadUtc(body["createTime"], out var created) || !ReadUtc(body["svrTime"], out var serverTime) ||
|
||||
cookies.GetCookies(api).Count == 0)
|
||||
return Result(AccountLoginError.Protocol, status);
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed || generation != requestGeneration || token.IsCancellationRequested)
|
||||
return Result(AccountLoginError.Cancelled, status);
|
||||
PlayerId = id;
|
||||
InternalId = internalId;
|
||||
CreateTimeUtc = created;
|
||||
ServerTimeUtc = serverTime;
|
||||
NewlyCreated = body["newCreate"].Value<bool>();
|
||||
IsAuthenticated = true;
|
||||
}
|
||||
return Result(AccountLoginError.None, status);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (gate)
|
||||
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
|
||||
? AccountLoginError.Cancelled : AccountLoginError.Timeout, status);
|
||||
}
|
||||
catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); }
|
||||
catch (HttpRequestException) { return Result(AccountLoginError.Network, status); }
|
||||
catch (JsonException) { return Result(AccountLoginError.Protocol, status); }
|
||||
catch (OverflowException) { return Result(AccountLoginError.Protocol, status); }
|
||||
}
|
||||
|
||||
/// <summary>Read-only probe: verifies that the server accepts this session cookie; never writes KV.</summary>
|
||||
public async Task<AccountLoginResult> CheckAuthorizationAsync(CancellationToken token = default)
|
||||
{
|
||||
HttpClient client;
|
||||
int requestGeneration;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed || !IsAuthenticated) return Result(AccountLoginError.Rejected);
|
||||
client = http;
|
||||
requestGeneration = generation;
|
||||
}
|
||||
int? status = null;
|
||||
try
|
||||
{
|
||||
using (var request = Request("playerdata/Get", new JObject()))
|
||||
using (var response = await client.SendAsync(request, token))
|
||||
{
|
||||
status = (int)response.StatusCode;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed || generation != requestGeneration || token.IsCancellationRequested)
|
||||
return Result(AccountLoginError.Cancelled, status);
|
||||
if (status == 401 || status == 403) ClearIdentity();
|
||||
}
|
||||
if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status);
|
||||
var body = Parse(await response.Content.ReadAsStringAsync());
|
||||
if (!(body["data"] is JObject data)) return Result(AccountLoginError.Protocol, status);
|
||||
foreach (var item in data.Properties())
|
||||
if (item.Value.Type != JTokenType.String) return Result(AccountLoginError.Protocol, status);
|
||||
lock (gate)
|
||||
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
|
||||
? AccountLoginError.Cancelled : AccountLoginError.None, status);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (gate)
|
||||
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
|
||||
? AccountLoginError.Cancelled : AccountLoginError.Timeout, status);
|
||||
}
|
||||
catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); }
|
||||
catch (HttpRequestException) { return Result(AccountLoginError.Network, status); }
|
||||
catch (JsonException) { return Result(AccountLoginError.Protocol, status); }
|
||||
}
|
||||
|
||||
HttpRequestMessage Request(string path, JObject body) => new HttpRequestMessage(HttpMethod.Post, new Uri(api, path))
|
||||
{ Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json") };
|
||||
|
||||
static JObject Parse(string text)
|
||||
{
|
||||
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
|
||||
{
|
||||
var value = JObject.Load(reader);
|
||||
if (reader.Read()) throw new JsonReaderException("Trailing JSON content.");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
static bool ReadUtc(JToken value, out DateTime date)
|
||||
{
|
||||
date = default;
|
||||
return value?.Type == JTokenType.String && DateTime.TryParse(value.Value<string>(), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out date);
|
||||
}
|
||||
|
||||
static AccountLoginResult Result(AccountLoginError error, int? status = null) => new AccountLoginResult(error, status);
|
||||
void ClearIdentity()
|
||||
{
|
||||
IsAuthenticated = false;
|
||||
PlayerId = null;
|
||||
InternalId = 0;
|
||||
CreateTimeUtc = default;
|
||||
ServerTimeUtc = default;
|
||||
NewlyCreated = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
generation++;
|
||||
ClearIdentity();
|
||||
http?.Dispose();
|
||||
http = null;
|
||||
Cookies = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 748d56d3a1b2489d85e081bfed580314
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,7 +8,7 @@ using game.PlayerDataStorage;
|
||||
|
||||
namespace game
|
||||
{
|
||||
public class AggKVUserService : KVUserService
|
||||
public class AggKvUserService : KvUserService
|
||||
{
|
||||
private bool channelLoginRunning;
|
||||
|
||||
@@ -262,6 +262,10 @@ namespace game
|
||||
}
|
||||
}
|
||||
|
||||
if (data.TryGetValue("EAccoutUrl", out var data_value))
|
||||
{
|
||||
EAccoutUrl = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, string>>(data_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ namespace game
|
||||
|
||||
if (!loginInfo.isSuccess) return ELoginStep.Sdk;
|
||||
|
||||
progress?.Invoke(1);
|
||||
progress.Invoke(1);
|
||||
|
||||
CustomId = loginInfo.StrUserId;
|
||||
|
||||
|
||||
@@ -32,54 +32,52 @@ namespace game
|
||||
}
|
||||
public class ClubService //: IClubService
|
||||
{
|
||||
[Inject] public PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
|
||||
[Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; }
|
||||
// BEGIN B01 fixed prompt owner
|
||||
readonly bool requiresFormalKV;
|
||||
PlayerDataStorage.PlayerKVGameSessionHandle promptOwner;
|
||||
internal PlayerDataStorage.PlayerKVEditResult LastPromptEdit { get; private set; }
|
||||
private readonly bool requiresFormalKV;
|
||||
private Core.KV.KvSessionOwner promptOwner;
|
||||
public ClubService() : this(RequireFrozenSelection()) { }
|
||||
static bool RequireFrozenSelection()
|
||||
private static bool RequireFrozenSelection()
|
||||
{
|
||||
var selection = GContext.container.Resolve<PlayerDataStorage.PlayerKVRuntimeSelection>();
|
||||
var selection = GContext.container.Resolve<game.KV.KvRuntimeSelection>();
|
||||
if (selection == null) throw DefaultSettingService.PromptFailure("IsOpenClubRed: startup selection missing.");
|
||||
return selection.IsKV;
|
||||
}
|
||||
internal ClubService(bool formalKV) { requiresFormalKV = formalKV; }
|
||||
internal ClubService(PlayerDataStorage.PlayerKVGameSessionHandle owner) : this(true)
|
||||
{
|
||||
promptOwner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
RequirePromptOwner();
|
||||
}
|
||||
// The constructor captures the registered startup selection, never a later mutable config value.
|
||||
internal void PreflightFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner)
|
||||
internal void PreflightFormalPromptOwner(Core.KV.KvSessionOwner owner)
|
||||
{
|
||||
if (!requiresFormalKV || owner == null || (promptOwner != null && !ReferenceEquals(promptOwner, owner)))
|
||||
{
|
||||
throw DefaultSettingService.PromptFailure("IsOpenClubRed: formal owner mismatch.");
|
||||
var read = KVMgr.ReadKV(owner, "IsOpenClubRed");
|
||||
}
|
||||
var read = game.KV.KvSaveStore.ReadKV(owner, "IsOpenClubRed");
|
||||
if (!read.Success)
|
||||
{
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={owner.OwnerInstanceId}: {read.Error}");
|
||||
}
|
||||
}
|
||||
internal void BindFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner)
|
||||
internal void BindFormalPromptOwner(Core.KV.KvSessionOwner owner)
|
||||
{
|
||||
PreflightFormalPromptOwner(owner);
|
||||
if (promptOwner == null) promptOwner = owner;
|
||||
if (promptOwner == null)
|
||||
{
|
||||
promptOwner = owner;
|
||||
}
|
||||
RequirePromptOwner();
|
||||
}
|
||||
internal bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) =>
|
||||
requiresFormalKV && ReferenceEquals(promptOwner, owner);
|
||||
|
||||
internal void ReleaseFailedPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner)
|
||||
internal void ReleaseFailedPromptOwner(Core.KV.KvSessionOwner owner)
|
||||
{
|
||||
if (promptOwner == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!ReferenceEquals(promptOwner, owner) || owner.Status != PlayerDataStorage.PlayerKVSyncStatus.Closed)
|
||||
if (!ReferenceEquals(promptOwner, owner) || owner.Status != Core.KV.KVSyncStatus.Closed)
|
||||
{
|
||||
throw DefaultSettingService.PromptFailure("IsOpenClubRed: failed owner has not closed.");
|
||||
}
|
||||
promptOwner = null;
|
||||
LastPromptEdit = null;
|
||||
}
|
||||
internal void RequirePromptOwner()
|
||||
{
|
||||
@@ -87,9 +85,9 @@ namespace game
|
||||
else if (requiresFormalKV)
|
||||
throw DefaultSettingService.PromptFailure("IsOpenClubRed: formal owner not handed off.");
|
||||
}
|
||||
private PlayerDataStorage.PlayerKVKeyReadResult ReadPrompt()
|
||||
private Core.KV.PlayerSaveReadResult ReadPrompt()
|
||||
{
|
||||
var read = KVMgr.ReadKV(promptOwner, "IsOpenClubRed");
|
||||
var read = game.KV.KvSaveStore.ReadKV(promptOwner, "IsOpenClubRed");
|
||||
if (!read.Success)
|
||||
{
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {read.Error}");
|
||||
@@ -213,10 +211,10 @@ namespace game
|
||||
{
|
||||
IsOpenClubRed = false;
|
||||
SetClubRed();
|
||||
LastPromptEdit = KVMgr.WriteKV(promptOwner, "IsOpenClubRed", "1");
|
||||
if (!LastPromptEdit.Accepted)
|
||||
var promptEdit = game.KV.KvSaveStore.WriteKV(promptOwner, "IsOpenClubRed", "1");
|
||||
if (!promptEdit.Success)
|
||||
{
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {promptEdit.Error}; uncertain={promptEdit.LocalCommitMayHaveSucceeded}");
|
||||
}
|
||||
}
|
||||
RequirePromptOwner();
|
||||
|
||||
@@ -20,7 +20,7 @@ public interface IDeferredRewardStashService
|
||||
|
||||
public class DeferredRewardStashService : IDeferredRewardStashService
|
||||
{
|
||||
readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve<game.PlayerDataStorage.IPlayerSaveStore>();
|
||||
readonly Core.KV.IPlayerSaveStore saveStore = GContext.container.Resolve<Core.KV.IPlayerSaveStore>();
|
||||
|
||||
private readonly TbItem _itemTable = GContext.container.Resolve<Tables>().TbItem;
|
||||
private readonly PlayerItemData _playerItemData = GContext.container.Resolve<PlayerItemData>();
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace game
|
||||
{
|
||||
public sealed class DevKVUserService : KVUserService
|
||||
{
|
||||
}
|
||||
}
|
||||
6
Client/Assets/Scripts/Services/DevKvUserService.cs
Normal file
6
Client/Assets/Scripts/Services/DevKvUserService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace game
|
||||
{
|
||||
public sealed class DevKvUserService : KvUserService
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public interface IFootPrintService
|
||||
public class FootPrintService : IFootPrintService
|
||||
{
|
||||
[asap.core.Inject]
|
||||
public game.PlayerDataStorage.IPlayerSaveStore SaveStore
|
||||
public Core.KV.IPlayerSaveStore SaveStore
|
||||
{
|
||||
get;
|
||||
set;
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace game
|
||||
{
|
||||
public class FriendService
|
||||
{
|
||||
[Inject] public PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
|
||||
[Inject] public Core.KV.IPlayerSaveStore SaveStore { get; set; }
|
||||
[Inject]
|
||||
public IUserService userService { get; set; }
|
||||
|
||||
@@ -142,7 +142,7 @@ namespace game
|
||||
}
|
||||
};
|
||||
|
||||
var result = userService is KVUserService
|
||||
var result = userService is KvUserService
|
||||
? await GetKVFriendsListAsync()
|
||||
: await PlayFabClientAsyncAPI.GetFriendsListAsync(getFriendsListRequest);
|
||||
var list = result?.Friends;
|
||||
|
||||
@@ -37,28 +37,14 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
#endif
|
||||
bool isInit = false;
|
||||
bool initing = false;
|
||||
DefaultSettingService promptSettings;
|
||||
Task initialization;
|
||||
Task opening;
|
||||
private DefaultSettingService promptSettings;
|
||||
private Task initialization;
|
||||
private Task opening;
|
||||
public LocalNotificationService()
|
||||
{
|
||||
_notificationList = GContext.container.Resolve<cfg.Tables>().TbNotification.DataList;
|
||||
}
|
||||
|
||||
internal LocalNotificationService(cfg.Tables tables)
|
||||
{
|
||||
_notificationList = (tables ?? throw new ArgumentNullException(nameof(tables))).TbNotification.DataList;
|
||||
}
|
||||
|
||||
internal LocalNotificationService(DefaultSettingService settings, cfg.Tables tables)
|
||||
{
|
||||
if (settings == null || !settings.HasKVPromptOwner)
|
||||
throw new ArgumentException("Fixed KV settings required.", nameof(settings));
|
||||
promptSettings = settings;
|
||||
_notificationList = (tables ?? throw new ArgumentNullException(nameof(tables))).TbNotification.DataList;
|
||||
RequirePromptOwner();
|
||||
}
|
||||
|
||||
internal void RequirePromptOwner(ISettingService expected = null)
|
||||
{
|
||||
if (expected != null && (promptSettings != null || expected is DefaultSettingService setting && setting.HasKVPromptOwner)
|
||||
@@ -84,8 +70,6 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
if (promptSettings == null) promptSettings = settings;
|
||||
RequirePromptOwner(settings);
|
||||
}
|
||||
internal bool HasFormalPromptSettings(DefaultSettingService settings) =>
|
||||
ReferenceEquals(promptSettings, settings);
|
||||
|
||||
public Task Init()
|
||||
{
|
||||
@@ -95,7 +79,7 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
return initialization = InitCoreAsync();
|
||||
}
|
||||
|
||||
async Task InitCoreAsync()
|
||||
private async Task InitCoreAsync()
|
||||
{
|
||||
RequirePromptOwner();
|
||||
#if UNITY_EDITOR
|
||||
@@ -156,7 +140,7 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
return opening = OpenSettingsCoreAsync();
|
||||
}
|
||||
|
||||
async Task OpenSettingsCoreAsync()
|
||||
private async Task OpenSettingsCoreAsync()
|
||||
{
|
||||
ISettingService settings = promptSettings ?? GContext.container.Resolve<ISettingService>();
|
||||
RequirePromptOwner(settings);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using Core.KV;
|
||||
using game.KV;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using game.PlayerDataStorage;
|
||||
using GameCore;
|
||||
using tysdk;
|
||||
using UnityEngine;
|
||||
@@ -20,62 +19,101 @@ using TapjoyUnity.Internal;
|
||||
namespace game
|
||||
{
|
||||
// Failed login attempts may retry; explicit logout ends this service lifetime.
|
||||
public class KVUserService : IUserService, IKVUserLifecycle
|
||||
public class KvUserService : IUserService
|
||||
{
|
||||
[Inject]
|
||||
public IConfig config { get; set; }
|
||||
public IConfig config
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[Inject]
|
||||
public ICustomServerMgr customServerMgr { get; set; }
|
||||
[Inject]
|
||||
public IPlayerSaveStore SaveStore { get; set; }
|
||||
[Inject]
|
||||
public ICustomServerMgr customServerMgr
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
[Inject]
|
||||
public IPlayerSaveStore SaveStore
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
//
|
||||
private readonly PlayerKVRuntimeSelection selection;
|
||||
//
|
||||
private readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
private readonly KvRuntimeSelection selection;
|
||||
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
private readonly Dictionary<string, PlayInfo> profiles = new();
|
||||
private readonly Dictionary<string, int> levels = new();
|
||||
|
||||
private PlayerKVGameLoadCoordinator coordinator;
|
||||
private PlayerKVGameSessionHandle handle;
|
||||
private bool loading, closing, published, promptsBound;
|
||||
private PlayerKVGameSessionHandle failedHandle;
|
||||
private KvGameLoadCoordinator coordinator;
|
||||
private KvSessionOwner handle;
|
||||
private bool loading, closing, published;
|
||||
private KvSessionOwner failedHandle;
|
||||
private int mmTestValue;
|
||||
private Task closeTask;
|
||||
private long epoch;
|
||||
private string playerId;
|
||||
private uint internalId;
|
||||
public string CustomId { get; set; }
|
||||
public string CustomId
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
public string UserId => KVHandoffReady ? playerId : null;
|
||||
public string ApiPlayerId => UserId;
|
||||
public uint InternalId => KVHandoffReady ? internalId : 0;
|
||||
public bool IsLogin => KVHandoffReady && playerId != null;
|
||||
public bool IsAuthorized => published && InternalId != 0 && KVHandoffReady;
|
||||
public CancellationToken LoginLifetime => lifetime.Token;
|
||||
public DateTime LastLoginTime { get; private set; }
|
||||
public DateTime LoginTime { get; private set; }
|
||||
public DateTime CreateTime { get; private set; }
|
||||
public string DisplayName { get; private set; }
|
||||
public string AvatarUrl { get; private set; }
|
||||
public string Language { get; private set; }
|
||||
public string ContinentCode { get; private set; }
|
||||
public Dictionary<string, string> EAccoutUrl { get; private set; } = new Dictionary<string, string>();
|
||||
internal PlayerKVGameLoadError KVLastError { get; private set; }
|
||||
public DateTime LastLoginTime
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
public DateTime LoginTime
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
public DateTime CreateTime
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
public string DisplayName
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
public string AvatarUrl
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
public string Language
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
public string ContinentCode
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
public Dictionary<string, string> EAccoutUrl
|
||||
{
|
||||
get;
|
||||
protected set;
|
||||
} = new Dictionary<string, string>();
|
||||
internal bool KVHandoffReady => !closing && handle != null && coordinator != null &&
|
||||
coordinator.TryGetCurrentGameHandle(epoch, out var current) && ReferenceEquals(current, handle);
|
||||
bool IKVUserLifecycle.KVHandoffReady => KVHandoffReady;
|
||||
PlayerKVGameLoadError IKVUserLifecycle.KVLastError => KVLastError;
|
||||
|
||||
public KVUserService() : this(SubBoostrap.RuntimeSelection, jar => new HttpClientHandler
|
||||
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
|
||||
|
||||
private KVUserService(PlayerKVRuntimeSelection selection, Func<CookieContainer, HttpMessageHandler> handlerFactory)
|
||||
public KvUserService()
|
||||
{
|
||||
this.selection = selection;
|
||||
this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
|
||||
selection = SubBoostrap.RuntimeSelection;
|
||||
Application.quitting += OnApplicationQuitting;
|
||||
}
|
||||
|
||||
@@ -99,7 +137,6 @@ namespace game
|
||||
}
|
||||
if (selection == null || !selection.IsKV || !selection.TryAdmitPlayer(CustomId, out var canonicalId))
|
||||
{
|
||||
KVLastError = PlayerKVGameLoadError.Input;
|
||||
return ELoginStep.Api;
|
||||
}
|
||||
|
||||
@@ -110,9 +147,9 @@ namespace game
|
||||
try
|
||||
{
|
||||
CustomServerMgr server;
|
||||
KVPlayerSaveStore store;
|
||||
PlayerKVGameLoadResult result;
|
||||
PlayerKVGameSessionHandle original;
|
||||
KvSaveStore store;
|
||||
KvGameLoadResult result;
|
||||
KvSessionOwner original;
|
||||
try
|
||||
{
|
||||
if (!await ReleaseFailedLoginAsync() || closing)
|
||||
@@ -120,20 +157,29 @@ namespace game
|
||||
return ELoginStep.Api;
|
||||
}
|
||||
server = customServerMgr as CustomServerMgr;
|
||||
if (server == null || server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl)
|
||||
if (server == null || server.ApiBaseUri.AbsoluteUri != selection.Api.AbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Formal KV transport required.");
|
||||
}
|
||||
coordinator = new PlayerKVGameLoadCoordinator(
|
||||
coordinator = new KvGameLoadCoordinator(
|
||||
() => loading && !closing && !published && handle == null,
|
||||
server.BindKVForGame, server.ClearKVForGame);
|
||||
server.KVUnauthorized += OnUnauthorized;
|
||||
var capability = PlayerKVGameLoadCapability.CreateForGame(selection.Api,
|
||||
Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory, prepareOriginalComparison: true);
|
||||
result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime,
|
||||
var api = selection.Api;
|
||||
var cacheRoot = Application.persistentDataPath;
|
||||
if (api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(api.UserInfo) || !string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment))
|
||||
{
|
||||
throw new ArgumentException("Formal KV endpoint mismatch.", nameof(api));
|
||||
}
|
||||
if (string.IsNullOrEmpty(cacheRoot))
|
||||
{
|
||||
throw new ArgumentException("Controlled load configuration invalid.");
|
||||
}
|
||||
api = new Uri(api.AbsoluteUri.TrimEnd('/') + "/");
|
||||
result = await coordinator.LoadForGameAsync(canonicalId, api, cacheRoot, LoginLifetime,
|
||||
() => !closing && CustomId == requestedId);
|
||||
KVLastError = result.Error;
|
||||
if (result.State != PlayerKVGameLoadState.PreparedForGame || result.Data == null ||
|
||||
if (result.Error != KVGameLoadError.None ||
|
||||
closing || CustomId != requestedId || result.Account == null ||
|
||||
result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch ||
|
||||
!coordinator.TryGetCurrentGameHandle(result.Epoch, out original))
|
||||
@@ -143,7 +189,7 @@ namespace game
|
||||
}
|
||||
epoch = result.Epoch;
|
||||
handle = original;
|
||||
store = SaveStore as KVPlayerSaveStore;
|
||||
store = SaveStore as KvSaveStore;
|
||||
if (store == null)
|
||||
{
|
||||
throw new InvalidOperationException("KV save store required.");
|
||||
@@ -152,7 +198,6 @@ namespace game
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
KVLastError = PlayerKVGameLoadError.Apply;
|
||||
Debug.LogError("KV preparation failed: " + ex.GetType().Name);
|
||||
await FailLoginAsync();
|
||||
return ELoginStep.Api;
|
||||
@@ -169,18 +214,16 @@ namespace game
|
||||
ZZTimeHelper.WathForcusChange();
|
||||
if (!original.CompareDatas(ZZTimeHelper.UtcNow().Ticks).Success)
|
||||
{
|
||||
KVLastError = PlayerKVGameLoadError.Apply;
|
||||
await FailLoginAsync();
|
||||
return ELoginStep.Api;
|
||||
}
|
||||
var merged = original.ReadLocalSnapshot();
|
||||
if (!merged.Success)
|
||||
{
|
||||
KVLastError = PlayerKVGameLoadError.Apply;
|
||||
await FailLoginAsync();
|
||||
return ELoginStep.Api;
|
||||
}
|
||||
KVMgr.Bind(store);
|
||||
KvMgr.Bind(store);
|
||||
InitializePlayerData(merged.Data.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal));
|
||||
if (!KVHandoffReady || CustomId != requestedId)
|
||||
{
|
||||
@@ -194,7 +237,6 @@ namespace game
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
KVLastError = PlayerKVGameLoadError.Apply;
|
||||
Debug.LogError("KV prompt binding failed: " + ex.GetType().Name);
|
||||
await FailLoginAsync();
|
||||
return ELoginStep.Api;
|
||||
@@ -236,7 +278,6 @@ namespace game
|
||||
{
|
||||
throw;
|
||||
}
|
||||
KVLastError = PlayerKVGameLoadError.Apply;
|
||||
Debug.LogError("KV login failed: " + ex.GetType().Name);
|
||||
try
|
||||
{
|
||||
@@ -294,7 +335,7 @@ namespace game
|
||||
InitData(data);
|
||||
}
|
||||
|
||||
private async Task PrepareProfileAsync(PlayerKVAccountDescriptor account)
|
||||
private async Task PrepareProfileAsync(KvAccountDescriptor account)
|
||||
{
|
||||
if (account.InternalId == 0 || account.CreateTimeUtc == DateTime.MinValue || account.ServerTimeUtc == DateTime.MinValue)
|
||||
{
|
||||
@@ -361,21 +402,18 @@ namespace game
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var status = await (coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready));
|
||||
if (closing || status != PlayerKVShutdownStatus.Ready || closeTask.Status != TaskStatus.RanToCompletion)
|
||||
var status = await (coordinator?.ObserveQuitAsync() ?? Task.FromResult(KVShutdownStatus.Ready));
|
||||
if (closing || status != KVShutdownStatus.Ready || closeTask.Status != TaskStatus.RanToCompletion)
|
||||
{
|
||||
KVLastError = status == PlayerKVShutdownStatus.Failed || closeTask.IsFaulted ?
|
||||
PlayerKVGameLoadError.PreviousCloseFailed : PlayerKVGameLoadError.PreviousClosePending;
|
||||
return false;
|
||||
}
|
||||
if (failedHandle != null)
|
||||
{
|
||||
KVMgr.Unbind(SaveStore as KVPlayerSaveStore);
|
||||
(SaveStore as KVPlayerSaveStore)?.ReleaseFailedBinding(failedHandle);
|
||||
KvMgr.Unbind(SaveStore as KvSaveStore);
|
||||
(SaveStore as KvSaveStore)?.ReleaseFailedBinding(failedHandle);
|
||||
(GContext.container.Resolve<ISettingService>() as DefaultSettingService)?.ReleaseFailedPromptOwner(failedHandle);
|
||||
GContext.container.Resolve<ClubService>().ReleaseFailedPromptOwner(failedHandle);
|
||||
}
|
||||
promptsBound = false;
|
||||
failedHandle = null;
|
||||
closeTask = null;
|
||||
coordinator = null;
|
||||
@@ -385,54 +423,67 @@ namespace game
|
||||
|
||||
internal static DateTime ParseLastLoginTime(string value, bool found)
|
||||
{
|
||||
if (!found) return DateTime.MinValue;
|
||||
if (!found)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
if (!DateTime.TryParseExact(value, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var time) ||
|
||||
time == DateTime.MinValue || time.Kind == DateTimeKind.Unspecified)
|
||||
{
|
||||
throw new FormatException("Invalid LastLoginTimeUtc.");
|
||||
}
|
||||
return time.ToUniversalTime();
|
||||
}
|
||||
|
||||
PlayerSaveReadResult Read(string key)
|
||||
private PlayerSaveReadResult Read(string key)
|
||||
{
|
||||
var result = SaveStore.Read(key);
|
||||
if (!result.Success) throw new InvalidOperationException("KV profile read failed: " + result.Error);
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException("KV profile read failed: " + result.Error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void BindPromptServices(PlayerKVGameSessionHandle original)
|
||||
private void BindPromptServices(KvSessionOwner original)
|
||||
{
|
||||
if (promptsBound || !KVHandoffReady) throw new InvalidOperationException("Prompt binding is stale.");
|
||||
if (!KVHandoffReady)
|
||||
{
|
||||
throw new InvalidOperationException("Prompt binding is stale.");
|
||||
}
|
||||
var settings = GContext.container.Resolve<ISettingService>() as DefaultSettingService;
|
||||
var notifications = GContext.container.Resolve<ILocalNotificationService>() as LocalNotificationService;
|
||||
var club = GContext.container.Resolve<ClubService>();
|
||||
if (settings == null || notifications == null || club == null ||
|
||||
!ReferenceEquals(settings, GContext.container.Resolve<ISettingService>()) ||
|
||||
!ReferenceEquals(notifications, GContext.container.Resolve<ILocalNotificationService>()) ||
|
||||
!ReferenceEquals(club, GContext.container.Resolve<ClubService>()))
|
||||
if (settings == null || notifications == null || club == null)
|
||||
{
|
||||
throw new InvalidOperationException("Formal prompt services required.");
|
||||
}
|
||||
settings.PreflightFormalPromptOwner(original);
|
||||
club.PreflightFormalPromptOwner(original);
|
||||
notifications.PreflightFormalPromptSettings(settings);
|
||||
promptsBound = true;
|
||||
settings.BindFormalPromptOwner(original);
|
||||
club.BindFormalPromptOwner(original);
|
||||
notifications.BindFormalPromptSettings(settings);
|
||||
if (!settings.HasFormalPromptOwner(original) || !club.HasFormalPromptOwner(original) ||
|
||||
!notifications.HasFormalPromptSettings(settings) || !KVHandoffReady)
|
||||
if (!KVHandoffReady)
|
||||
{
|
||||
throw new InvalidOperationException("Formal prompt binding changed.");
|
||||
}
|
||||
}
|
||||
|
||||
void OnUnauthorized(int version)
|
||||
private void OnUnauthorized(int version)
|
||||
{
|
||||
if (version == customServerMgr.SessionVersion) Logout();
|
||||
if (version == customServerMgr.SessionVersion)
|
||||
{
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationQuitting()
|
||||
{
|
||||
var original = SaveStore as KVPlayerSaveStore;
|
||||
var original = SaveStore as KvSaveStore;
|
||||
try
|
||||
{
|
||||
KVMgr.Stop(original);
|
||||
KvMgr.Stop(original);
|
||||
if (original != null && original.IsCurrent)
|
||||
{
|
||||
_ = original.SyncDataAsync();
|
||||
@@ -452,10 +503,16 @@ namespace game
|
||||
{
|
||||
ObserveClose(CloseKVForValidationAsync());
|
||||
}
|
||||
async void ObserveClose(Task task)
|
||||
private async void ObserveClose(Task task)
|
||||
{
|
||||
try { await task; }
|
||||
catch (Exception ex) { Debug.LogError("KV close failed: " + ex.GetType().Name); }
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError("KV close failed: " + ex.GetType().Name);
|
||||
}
|
||||
}
|
||||
internal Task CloseKVForValidationAsync()
|
||||
{
|
||||
@@ -465,8 +522,8 @@ namespace game
|
||||
}
|
||||
closing = true;
|
||||
published = false;
|
||||
KVMgr.Unbind(SaveStore as KVPlayerSaveStore);
|
||||
(SaveStore as KVPlayerSaveStore)?.Dispose();
|
||||
KvMgr.Unbind(SaveStore as KvSaveStore);
|
||||
(SaveStore as KvSaveStore)?.Dispose();
|
||||
playerId = null;
|
||||
internalId = 0;
|
||||
handle = null;
|
||||
@@ -484,10 +541,8 @@ namespace game
|
||||
lifetime.Cancel();
|
||||
return closeTask;
|
||||
}
|
||||
Task IKVUserLifecycle.CloseKVForValidationAsync() => CloseKVForValidationAsync();
|
||||
internal Task<PlayerKVShutdownStatus> ObserveKVQuitAsync() =>
|
||||
coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready);
|
||||
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
|
||||
internal Task<KVShutdownStatus> ObserveKVQuitAsync() =>
|
||||
coordinator?.ObserveQuitAsync() ?? Task.FromResult(KVShutdownStatus.Ready);
|
||||
|
||||
private async Task<bool> SaveProfile(string key, string value)
|
||||
{
|
||||
@@ -667,18 +722,105 @@ namespace game
|
||||
};
|
||||
Debug.Log($"JsonUtility.ToJson(versionData) => {JsonConvert.SerializeObject(versionData)}");
|
||||
|
||||
#if (UNITY_ANDROID || UNITY_IOS || UNITY_IPHONE) && !UNITY_EDITOR
|
||||
var businessId = GConstant.Yidun_AndroidBusinessId;
|
||||
#if UNITY_IOS
|
||||
businessId = GConstant.Yidun_IOSBusinessId;
|
||||
#endif
|
||||
|
||||
#if (UNITY_ANDROID || UNITY_IOS || UNITY_IPHONE) && !UNITY_EDITOR
|
||||
yiDunManager.SetRoleInfo(businessId, roleId, roleName, roleAccount, null, -1, JsonConvert.SerializeObject(versionData));
|
||||
yiDunManager.PostRiskCheck();
|
||||
#endif
|
||||
}
|
||||
|
||||
public double CreateTotalSeconds() => (ZZTimeHelper.UtcNow() - CreateTime).TotalSeconds;
|
||||
|
||||
private sealed class BindConflictPreviewResponse
|
||||
{
|
||||
public int Code
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
public bool Exists
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
public bool HasArchive
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
public string DisplayName
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
public string AvatarUrl
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
public int Level
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlayFabTools.PFPlayerInfo> GetBindConflictPreview(int sdkId)
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = null;
|
||||
for (var attempt = 0; attempt < 4; attempt++)
|
||||
{
|
||||
json = await customServerMgr.AccountRequest("GetBindConflictPreview",
|
||||
new { targetUserId = sdkId.ToString() });
|
||||
if (json != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (attempt < 3)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1 << attempt));
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return PlayFabTools.PFPlayerInfo.Empty(sdkId);
|
||||
}
|
||||
|
||||
var response = JsonConvert.DeserializeObject<BindConflictPreviewResponse>(json);
|
||||
if (response == null || response.Code <= 0 || !response.Exists)
|
||||
{
|
||||
return PlayFabTools.PFPlayerInfo.Empty(sdkId);
|
||||
}
|
||||
if (!response.HasArchive)
|
||||
{
|
||||
return new PlayFabTools.PFPlayerInfo
|
||||
{
|
||||
SdkId = sdkId,
|
||||
PlayFabId = sdkId.ToString()
|
||||
};
|
||||
}
|
||||
return new PlayFabTools.PFPlayerInfo
|
||||
{
|
||||
SdkId = sdkId,
|
||||
PlayFabId = sdkId.ToString(),
|
||||
DisplayName = response.DisplayName,
|
||||
Avatar = response.AvatarUrl,
|
||||
Level = response.Level
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning(e.Message);
|
||||
return PlayFabTools.PFPlayerInfo.Empty(sdkId);
|
||||
}
|
||||
}
|
||||
|
||||
// The non-AGG channel has no account-link operation; never manufacture a successful binding.
|
||||
public virtual async Task<(int code, int userid)> LinkAccount(EAccoutType type)
|
||||
{
|
||||
@@ -730,7 +872,10 @@ namespace game
|
||||
}
|
||||
public PlayInfo GetPlayInfo(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return null;
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!profiles.TryGetValue(id, out var profile))
|
||||
{
|
||||
profile = new PlayInfo { name = GetDefaultName(id) };
|
||||
@@ -781,7 +926,11 @@ namespace game
|
||||
avatarUrl = avatarUrl
|
||||
};
|
||||
}
|
||||
public void ClearPlayInfos() { profiles.Clear(); levels.Clear(); }
|
||||
public void ClearPlayInfos()
|
||||
{
|
||||
profiles.Clear();
|
||||
levels.Clear();
|
||||
}
|
||||
private void UpdateProfileName()
|
||||
{
|
||||
if (!IsAuthorized)
|
||||
@@ -808,13 +957,25 @@ namespace game
|
||||
}
|
||||
public void UpdateProfile()
|
||||
{
|
||||
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,
|
||||
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv, Avatar = AvatarUrl, DisplayName = DisplayName } });
|
||||
if (IsAuthorized)
|
||||
{
|
||||
customServerMgr.AccountRequest("UpdateProfile", new LoginRequest
|
||||
{
|
||||
playerId = ApiPlayerId,
|
||||
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv, Avatar = AvatarUrl, DisplayName = DisplayName }
|
||||
});
|
||||
}
|
||||
}
|
||||
public void UpdateProfileLv()
|
||||
{
|
||||
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,
|
||||
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv } });
|
||||
if (IsAuthorized)
|
||||
{
|
||||
customServerMgr.AccountRequest("UpdateProfile", new LoginRequest
|
||||
{
|
||||
playerId = ApiPlayerId,
|
||||
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal interface IKVUserLifecycle
|
||||
{
|
||||
bool KVHandoffReady { get; }
|
||||
PlayerKVGameLoadError KVLastError { get; }
|
||||
Task CloseKVForValidationAsync();
|
||||
Task<PlayerKVShutdownStatus> ObserveKVQuitAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12a766df765ed0d4981d8da38eead5e3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,51 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// A store belongs to one login. Callers retain this instance across awaits.
|
||||
public interface IPlayerSaveStore
|
||||
{
|
||||
bool IsCurrent { get; }
|
||||
PlayerSaveReadResult Read(string key);
|
||||
PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true);
|
||||
Task<bool> SyncDataImmidiate(string key, string value);
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveReadResult
|
||||
{
|
||||
public bool Success => Error == null;
|
||||
public bool? Found { get; }
|
||||
public string Value { get; }
|
||||
public string Error { get; }
|
||||
internal PlayerSaveReadResult(bool found, string value) { Found = found; Value = value; }
|
||||
internal PlayerSaveReadResult(string error) { Error = error ?? "ReadFailed"; }
|
||||
public string ValueOrThrow()
|
||||
{
|
||||
if (!Success) throw new InvalidOperationException("Player save read failed: " + Error);
|
||||
return Value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveReceipt
|
||||
{
|
||||
internal readonly IPlayerSaveStore Store;
|
||||
internal readonly object Ticket;
|
||||
internal PlayerSaveReceipt(IPlayerSaveStore store, object ticket) { Store = store; Ticket = ticket; }
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveWriteResult
|
||||
{
|
||||
public bool Accepted => Error == null;
|
||||
public string Error { get; }
|
||||
public PlayerSaveReceipt Receipt { get; }
|
||||
internal PlayerSaveWriteResult(PlayerSaveReceipt receipt) { Receipt = receipt; }
|
||||
internal PlayerSaveWriteResult(string error) { Error = error ?? "WriteRejected"; }
|
||||
public void RequireAccepted()
|
||||
{
|
||||
if (!Accepted) throw new InvalidOperationException("Player save write rejected: " + Error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7a9c9b2dab8e5a4ca300f0bcf6637e8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
285
Client/Assets/Scripts/Services/PlayerData/KvAccountLease.cs
Normal file
285
Client/Assets/Scripts/Services/PlayerData/KvAccountLease.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.KV
|
||||
{
|
||||
public enum KVLoginPrepareError
|
||||
{
|
||||
None,
|
||||
Validation,
|
||||
Cancelled,
|
||||
Stale,
|
||||
AuthenticationFailed,
|
||||
OwnerRejected,
|
||||
NotReady,
|
||||
Unexpected,
|
||||
CloseFailed
|
||||
}
|
||||
|
||||
internal sealed class KvAccountDescriptor
|
||||
{
|
||||
internal string PlayerId
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal long Epoch
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal uint InternalId
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal bool NewlyCreated
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal DateTime CreateTimeUtc
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal DateTime ServerTimeUtc
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvAccountDescriptor(string playerId, long epoch, uint internalId,
|
||||
bool newlyCreated, DateTime createTimeUtc, DateTime serverTimeUtc)
|
||||
{
|
||||
PlayerId = playerId;
|
||||
Epoch = epoch;
|
||||
InternalId = internalId;
|
||||
NewlyCreated = newlyCreated;
|
||||
CreateTimeUtc = createTimeUtc;
|
||||
ServerTimeUtc = serverTimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal sealed class KvAccountResponse
|
||||
{
|
||||
internal bool IsCurrent
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal int? StatusCode
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal string Body
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvAccountResponse(bool isCurrent, int? statusCode = null, string body = null)
|
||||
{
|
||||
IsCurrent = isCurrent;
|
||||
StatusCode = isCurrent ? statusCode : null;
|
||||
Body = isCurrent ? body : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A context-owned account transport. Close drains before disposing its client.</summary>
|
||||
internal sealed class KvAccountLease
|
||||
{
|
||||
private readonly object gate = new object();
|
||||
private readonly HttpClient client;
|
||||
private readonly CookieContainer cookies;
|
||||
private readonly Func<bool> current;
|
||||
private readonly Func<bool> admission;
|
||||
private readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
private TaskCompletionSource<bool> drained;
|
||||
private TaskCompletionSource<bool> closing;
|
||||
private int active;
|
||||
internal Uri ApiBaseUri
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal long Epoch
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal CookieContainer FormalCookies => IsCurrent ? cookies : null;
|
||||
internal bool IsCurrent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
if (closing != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return current();
|
||||
}
|
||||
}
|
||||
|
||||
internal KvAccountLease(Uri api, long epoch, CookieContainer cookies,
|
||||
Func<CookieContainer, HttpMessageHandler> capability, Func<bool> current, Func<bool> admission)
|
||||
{
|
||||
if (capability == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(capability));
|
||||
}
|
||||
ApiBaseUri = api;
|
||||
Epoch = epoch;
|
||||
this.cookies = cookies;
|
||||
this.current = current;
|
||||
this.admission = admission;
|
||||
client = new HttpClient(capability(cookies)) { BaseAddress = api };
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
internal async Task<KvAccountResponse> SendAsync(HttpMethod method, string route,
|
||||
string body = null, string contentType = "application/json", CancellationToken token = default)
|
||||
{
|
||||
if (!IsCanonicalBusinessRoute(route))
|
||||
{
|
||||
throw new ArgumentException("Only canonical relative business routes are allowed.", nameof(route));
|
||||
}
|
||||
if (method != HttpMethod.Get && method != HttpMethod.Post)
|
||||
{
|
||||
throw new ArgumentException("Unsupported lease method.", nameof(method));
|
||||
}
|
||||
if (!IsCurrent)
|
||||
{
|
||||
return new KvAccountResponse(false);
|
||||
}
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || !admission())
|
||||
{
|
||||
return new KvAccountResponse(false);
|
||||
}
|
||||
active++;
|
||||
}
|
||||
try
|
||||
{
|
||||
using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, lifetime.Token))
|
||||
using (var request = new HttpRequestMessage(method, route))
|
||||
{
|
||||
if (body != null)
|
||||
{
|
||||
request.Content = new StringContent(body, System.Text.Encoding.UTF8, contentType);
|
||||
}
|
||||
using (var response = await client.SendAsync(request, linked.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (!IsCurrent || linked.IsCancellationRequested)
|
||||
{
|
||||
return new KvAccountResponse(false);
|
||||
}
|
||||
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
// Do not follow redirects from the authenticated business endpoint.
|
||||
if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400)
|
||||
{
|
||||
throw new InvalidOperationException("Redirects are not accepted.");
|
||||
}
|
||||
return IsCurrent && !linked.IsCancellationRequested
|
||||
? new KvAccountResponse(true, (int)response.StatusCode, text)
|
||||
: new KvAccountResponse(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!IsCurrent || token.IsCancellationRequested || lifetime.IsCancellationRequested)
|
||||
{
|
||||
return new KvAccountResponse(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
active--;
|
||||
if (active == 0)
|
||||
{
|
||||
drained?.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCanonicalBusinessRoute(string route)
|
||||
{
|
||||
if (string.IsNullOrEmpty(route) || route[0] == '/' || route[0] == '\\' ||
|
||||
Uri.TryCreate(route, UriKind.Absolute, out _) ||
|
||||
route.IndexOf("//", StringComparison.Ordinal) >= 0 ||
|
||||
route.IndexOfAny(new[] { '?', '#', '%', '\\' }) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var segments = route.Split('/');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment.Length == 0 || segment == "." || segment == "..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (var character in segment)
|
||||
if (char.IsControl(character))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return !route.Equals("account/Login", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.Equals("playerdata", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.StartsWith("playerdata/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null)
|
||||
{
|
||||
return closing.Task;
|
||||
}
|
||||
completion = closing = Signal();
|
||||
drained = Signal();
|
||||
if (active == 0)
|
||||
{
|
||||
drained.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
_ = completion.Task.ContinueWith(t =>
|
||||
{
|
||||
_ = t.Exception;
|
||||
}, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Exception cancellationFailure = null;
|
||||
try
|
||||
{
|
||||
lifetime.Cancel();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
cancellationFailure = ex;
|
||||
}
|
||||
await drained.Task.ConfigureAwait(false);
|
||||
client.Dispose();
|
||||
lifetime.Dispose();
|
||||
if (cancellationFailure != null)
|
||||
{
|
||||
throw cancellationFailure;
|
||||
}
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completion.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<bool> Signal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
1054
Client/Assets/Scripts/Services/PlayerData/KvGameLoadCoordinator.cs
Normal file
1054
Client/Assets/Scripts/Services/PlayerData/KvGameLoadCoordinator.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace game.KV
|
||||
{
|
||||
/// <summary>
|
||||
/// Immutable process-start selection with canonical positive numeric player identities.
|
||||
/// </summary>
|
||||
internal sealed class KvRuntimeSelection
|
||||
{
|
||||
internal bool IsKV
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal Uri Api
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
private KvRuntimeSelection(bool isKV, Uri api)
|
||||
{
|
||||
IsKV = isKV;
|
||||
Api = api;
|
||||
}
|
||||
|
||||
internal static string CompiledBackend
|
||||
{
|
||||
get
|
||||
{
|
||||
#if USE_KV
|
||||
return "kv";
|
||||
#else
|
||||
return "playfab";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
internal static KvRuntimeSelection CaptureForBuild(string api)
|
||||
{
|
||||
var backend = CompiledBackend;
|
||||
if (backend == "playfab")
|
||||
{
|
||||
return new KvRuntimeSelection(false, null);
|
||||
}
|
||||
if (!Uri.TryCreate(api, UriKind.Absolute, out var address) ||
|
||||
(address.Scheme != "http" && address.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(address.UserInfo) || !string.IsNullOrEmpty(address.Query) ||
|
||||
!string.IsNullOrEmpty(address.Fragment))
|
||||
{
|
||||
throw new ArgumentException("KV API address is invalid.", nameof(api));
|
||||
}
|
||||
return new KvRuntimeSelection(true, new Uri(address.AbsoluteUri.TrimEnd('/') + "/"));
|
||||
}
|
||||
|
||||
internal bool TryAdmitPlayer(string requestedId, out string canonicalId)
|
||||
{
|
||||
canonicalId = null;
|
||||
if (!IsKV || !long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) ||
|
||||
id <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
canonicalId = id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,609 +0,0 @@
|
||||
#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,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 04d2914963285d343b5a0e9503b59390
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,61 @@
|
||||
using Core.KV;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public sealed class PlayFabSaveStore : IPlayerSaveStore
|
||||
{
|
||||
public Task<bool> SyncDataImmidiate(string key, string value)
|
||||
{
|
||||
return manager.SyncDataImmidiate(key, value);
|
||||
}
|
||||
|
||||
private readonly PlayFabMgr manager;
|
||||
private readonly CancellationToken lifetime;
|
||||
public PlayFabSaveStore(IUserService user)
|
||||
{
|
||||
var originalManager = PlayFabMgr.Instance;
|
||||
var originalLifetime = user.LoginLifetime;
|
||||
manager = originalManager;
|
||||
lifetime = originalLifetime;
|
||||
}
|
||||
public bool IsCurrent => manager != null && !lifetime.IsCancellationRequested;
|
||||
|
||||
public PlayerSaveReadResult Read(string key)
|
||||
{
|
||||
if (!IsCurrent)
|
||||
{
|
||||
return new PlayerSaveReadResult("StaleOrNotReady");
|
||||
}
|
||||
try
|
||||
{
|
||||
var value = manager.GetLocalData(key);
|
||||
return new PlayerSaveReadResult(value != null, value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PlayerSaveReadResult(ex.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
if (!IsCurrent)
|
||||
{
|
||||
return new PlayerSaveWriteResult("StaleOrNotReady");
|
||||
}
|
||||
try
|
||||
{
|
||||
manager.UpdateUserDataValue(key, value, remoteStore);
|
||||
return new PlayerSaveWriteResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PlayerSaveWriteResult(ex.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Internal seam for deterministic disk-failure tests; no production callback is supplied.
|
||||
internal enum KVCacheWriteStage { BeforeWrite, AfterWrite, AfterFlush, BeforeReplace, AfterReplace }
|
||||
|
||||
internal sealed class PlayerKVCacheStore : IDisposable
|
||||
{
|
||||
const long MaxBytes = 64L * 1024 * 1024;
|
||||
readonly KVIdentity identity;
|
||||
readonly FileStream lease;
|
||||
readonly Action<KVCacheWriteStage> fault;
|
||||
readonly string main, temporary, backup;
|
||||
bool disposed;
|
||||
|
||||
PlayerKVCacheStore(KVIdentity identity, FileStream lease, Action<KVCacheWriteStage> fault)
|
||||
{
|
||||
this.identity = identity; this.lease = lease; this.fault = fault;
|
||||
main = Path.Combine(identity.DirectoryPath, "state.json");
|
||||
temporary = main + ".tmp"; backup = main + ".bak";
|
||||
}
|
||||
|
||||
internal static PlayerKVOpenError Open(KVIdentity identity, Action<KVCacheWriteStage> fault,
|
||||
out PlayerKVCacheStore store, out KVSnapshot snapshot)
|
||||
{
|
||||
store = null; snapshot = null;
|
||||
FileStream lease = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(identity.DirectoryPath);
|
||||
try { lease = new FileStream(Path.Combine(identity.DirectoryPath, "state.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); }
|
||||
catch (IOException ex) when ((ex.HResult & 0xffff) == 32 || (ex.HResult & 0xffff) == 33)
|
||||
{ return PlayerKVOpenError.Busy; }
|
||||
store = new PlayerKVCacheStore(identity, lease, fault);
|
||||
if (!File.Exists(store.main))
|
||||
{
|
||||
if (File.Exists(store.backup) || File.Exists(store.temporary)) throw KVSnapshot.Invalid();
|
||||
snapshot = new KVSnapshot();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (new FileInfo(store.main).Length > MaxBytes) throw KVSnapshot.Invalid();
|
||||
snapshot = KVSnapshot.Decode(File.ReadAllText(store.main, new UTF8Encoding(false, true)), identity);
|
||||
}
|
||||
return PlayerKVOpenError.None;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException || ex is OverflowException || ex is FormatException || ex is DecoderFallbackException)
|
||||
{
|
||||
store?.Dispose(); lease?.Dispose(); store = null;
|
||||
return PlayerKVOpenError.RecoveryRequired;
|
||||
}
|
||||
catch (Exception ex) when (DiskError(ex))
|
||||
{
|
||||
store?.Dispose(); lease?.Dispose(); store = null;
|
||||
return PlayerKVOpenError.StorageError;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool Save(KVSnapshot snapshot, out bool mayHaveCommitted)
|
||||
{
|
||||
mayHaveCommitted = false;
|
||||
try
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(PlayerKVCacheStore));
|
||||
snapshot.Validate();
|
||||
var bytes = new UTF8Encoding(false, true).GetBytes(snapshot.Encode(identity));
|
||||
if (bytes.LongLength > MaxBytes) throw new IOException("KV cache capacity exceeded.");
|
||||
fault?.Invoke(KVCacheWriteStage.BeforeWrite);
|
||||
using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
fault?.Invoke(KVCacheWriteStage.AfterWrite);
|
||||
stream.Flush(true);
|
||||
fault?.Invoke(KVCacheWriteStage.AfterFlush);
|
||||
}
|
||||
fault?.Invoke(KVCacheWriteStage.BeforeReplace);
|
||||
mayHaveCommitted = true;
|
||||
// The temp file is on the same filesystem. Never delete the main file first.
|
||||
if (File.Exists(main)) File.Replace(temporary, main, backup);
|
||||
else File.Move(temporary, main);
|
||||
fault?.Invoke(KVCacheWriteStage.AfterReplace);
|
||||
mayHaveCommitted = false;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (DiskError(ex) || ex is JsonException || ex is EncoderFallbackException)
|
||||
{ return false; }
|
||||
}
|
||||
|
||||
static bool DiskError(Exception ex) => ex is IOException || ex is UnauthorizedAccessException ||
|
||||
ex is SecurityException || ex is NotSupportedException || ex is ObjectDisposedException;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lease.Dispose();
|
||||
// Keep files, including failed temporary writes, for explicit recovery diagnosis.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb1e30eab9b24686a5b5110bc68c19b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,178 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Private KV protocol for one fixed authenticated account/environment.
|
||||
/// Caller owns the HttpClient, cookies and cancellation lifetime; never reuse
|
||||
/// that transport for another account. This class neither retries nor logs payloads.
|
||||
/// Production login/storage integration is deliberately outside this adapter.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVClient
|
||||
{
|
||||
readonly HttpClient http;
|
||||
readonly Uri api;
|
||||
|
||||
public PlayerKVClient(HttpClient http, Uri apiBaseUri)
|
||||
{
|
||||
this.http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
if (apiBaseUri == null || !apiBaseUri.IsAbsoluteUri ||
|
||||
(apiBaseUri.Scheme != "http" && apiBaseUri.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(apiBaseUri.UserInfo) ||
|
||||
!string.IsNullOrEmpty(apiBaseUri.Query) || !string.IsNullOrEmpty(apiBaseUri.Fragment))
|
||||
throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(apiBaseUri));
|
||||
api = new Uri(apiBaseUri.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
public Task<PlayerKVResult> GetAsync(IEnumerable<string> keys = null, CancellationToken token = default)
|
||||
{
|
||||
if (!CopyKeys(keys, true, out var snapshot)) return Invalid();
|
||||
var body = new JObject();
|
||||
if (snapshot.Count > 0) body["keys"] = new JArray(snapshot);
|
||||
return SendAsync("Get", body, false, token);
|
||||
}
|
||||
|
||||
public Task<PlayerKVResult> SetAsync(IDictionary<string, string> data, CancellationToken token = default)
|
||||
{
|
||||
if (data == null) return Invalid();
|
||||
var snapshot = new JObject();
|
||||
foreach (var pair in data)
|
||||
{
|
||||
if (!ValidKey(pair.Key) || pair.Value == null) return Invalid();
|
||||
snapshot.Add(pair.Key, pair.Value);
|
||||
}
|
||||
return SendAsync("Set", new JObject { ["data"] = snapshot }, true, token);
|
||||
}
|
||||
|
||||
public Task<PlayerKVResult> DeleteAsync(IEnumerable<string> keys, CancellationToken token = default)
|
||||
{
|
||||
if (!CopyKeys(keys, false, out var snapshot)) return Invalid();
|
||||
return SendAsync("Delete", new JObject { ["keys"] = new JArray(snapshot) }, true, token);
|
||||
}
|
||||
|
||||
static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128;
|
||||
|
||||
static bool CopyKeys(IEnumerable<string> keys, bool allowNull, out List<string> snapshot)
|
||||
{
|
||||
snapshot = new List<string>();
|
||||
if (keys == null) return allowNull;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!ValidKey(key)) return false;
|
||||
snapshot.Add(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Task<PlayerKVResult> Invalid() => Task.FromResult(new PlayerKVResult(PlayerKVError.Validation));
|
||||
|
||||
async Task<PlayerKVResult> SendAsync(string action, JObject body, bool mutation, CancellationToken token)
|
||||
{
|
||||
if (token.IsCancellationRequested) return new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
int? status = null;
|
||||
bool enteredTransport = false;
|
||||
try
|
||||
{
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(api, "playerdata/" + action)))
|
||||
{
|
||||
request.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json");
|
||||
token.ThrowIfCancellationRequested();
|
||||
enteredTransport = true;
|
||||
using (var response = await http.SendAsync(request, token))
|
||||
{
|
||||
status = (int)response.StatusCode;
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
return new PlayerKVResult(status == 401 || status == 403
|
||||
? PlayerKVError.Unauthorized : PlayerKVError.Http, status,
|
||||
mayHaveCommitted: mutation);
|
||||
if (mutation) return new PlayerKVResult(PlayerKVError.None, status);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
token.ThrowIfCancellationRequested();
|
||||
var data = ParseData(text, out var updatedAtUtc);
|
||||
return new PlayerKVResult(PlayerKVError.None, status, data, updatedAtUtc: updatedAtUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new PlayerKVResult(token.IsCancellationRequested ? PlayerKVError.Cancelled : PlayerKVError.Timeout,
|
||||
status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Cancelled, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Protocol, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
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.");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcdbe8122e0b4f9eb82d2d1579081a7d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,162 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Prebuild distinct transports with one cookie jar, then transfer each owned handler once.
|
||||
internal sealed class PlayerKVGameLoadCapability
|
||||
{
|
||||
internal Uri Api { get; }
|
||||
internal string CacheRoot { get; }
|
||||
internal int LoginObserveMs { get; }
|
||||
internal int QuitObserveMs { get; }
|
||||
internal bool RequireExistingArchive { get; }
|
||||
internal Func<int,CancellationToken,Task> Delay { get; }
|
||||
internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; }
|
||||
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(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)
|
||||
{
|
||||
// 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;
|
||||
RequireExistingArchive = requireExistingArchive;
|
||||
this.prepareOriginalComparison = prepareOriginalComparison;
|
||||
}
|
||||
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(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(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(api, cacheRoot, loginMs, quitMs, delay, auth, kv, lease);
|
||||
capability.LeaseCapability = PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
|
||||
return capability;
|
||||
}
|
||||
#endif
|
||||
internal PlayerKVLoginContext CreateContext(string id,long epoch,Func<long,bool> current)
|
||||
{
|
||||
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, prepareOriginalComparison));
|
||||
}
|
||||
private HttpMessageHandler CreateAuthentication(CookieContainer jar)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
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 = 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
|
||||
{
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
static bool ShareHandlerGraph(params HttpMessageHandler[] roots)
|
||||
{
|
||||
var seen=new System.Collections.Generic.List<HttpMessageHandler>();
|
||||
foreach(var root in roots)
|
||||
for(var node=root;node!=null;node=(node as DelegatingHandler)?.InnerHandler)
|
||||
{
|
||||
if(seen.Exists(previous=>ReferenceEquals(previous,node))) return true;
|
||||
seen.Add(node);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
HttpMessageHandler TakeKV(CookieContainer jar)
|
||||
{
|
||||
lock(gate)
|
||||
{ if(!ReferenceEquals(jar,cookies) || pendingKV==null) throw new InvalidOperationException("KV transport ownership invalid.");
|
||||
var result=pendingKV; pendingKV=null; return result; }
|
||||
}
|
||||
HttpMessageHandler TakeLease(CookieContainer jar)
|
||||
{
|
||||
lock(gate)
|
||||
{ if(!ReferenceEquals(jar,cookies) || pendingLease==null) throw new InvalidOperationException("Lease transport ownership invalid.");
|
||||
var result=pendingLease; pendingLease=null; return result; }
|
||||
}
|
||||
internal void CloseUnused()
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
// Attempt every owned cleanup once; retain a failure even if a later Dispose succeeds.
|
||||
var ownedLease=pendingLease; pendingLease=null;
|
||||
var ownedKV=pendingKV; pendingKV=null;
|
||||
try { ownedLease?.Dispose(); } catch(Exception ex) { cleanupFailure=cleanupFailure ?? ex; failedHandlers.Add(ownedLease); }
|
||||
try { ownedKV?.Dispose(); } catch(Exception ex) { cleanupFailure=cleanupFailure ?? ex; failedHandlers.Add(ownedKV); }
|
||||
if(cleanupFailure!=null) throw new InvalidOperationException("Controlled transport cleanup failed.",cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89934f8eb7b441bcb60409e83a018b76
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46103e7278684f2db18a8cb6340a4b76
|
||||
@@ -1,456 +0,0 @@
|
||||
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,
|
||||
PreparedForGame
|
||||
}
|
||||
|
||||
// Preserve stage evidence without retaining a successful Prepare payload on a later load failure.
|
||||
internal sealed class PlayerKVPrepareEvidence
|
||||
{
|
||||
internal PlayerKVLoginPrepareError Error { get; }
|
||||
internal PlayerKVLoginPrepareError OriginalError { get; }
|
||||
internal game.Account.AccountLoginError? AccountError { get; }
|
||||
internal PlayerKVGameOpenError? OpenError { get; }
|
||||
internal PlayerKVSessionOpenError? OwnerError { get; }
|
||||
internal PlayerKVError? KVError { get; }
|
||||
internal PlayerKVSyncStatus? SyncStatus { get; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVGameLoadResult
|
||||
{
|
||||
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
|
||||
{
|
||||
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(Func<bool> unstarted, Action<PlayerKVAccountLease> bind, Func<Task> unbind, PlayerKVShutdownRegistry registry = null)
|
||||
{
|
||||
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> LoadForGameAsync(string id, PlayerKVGameLoadCapability next,
|
||||
CancellationToken token = default, Func<bool> identityCurrent = null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 (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)
|
||||
{
|
||||
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;
|
||||
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())
|
||||
{
|
||||
return Result(PlayerKVGameLoadError.Input);
|
||||
}
|
||||
|
||||
if (!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard))
|
||||
{
|
||||
return Result(PlayerKVGameLoadError.GateBusy);
|
||||
}
|
||||
|
||||
lifetimeRegistration.Dispose();
|
||||
lock (closeGate)
|
||||
{
|
||||
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)))
|
||||
{
|
||||
if (await Task.WhenAny(work, cancelled.Task) != work)
|
||||
{
|
||||
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)
|
||||
{
|
||||
gameHandle = original;
|
||||
published = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
cancellation.Cancel();
|
||||
try
|
||||
{
|
||||
await wait;
|
||||
acquired = true;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal Task Revoke(long? expectedEpoch = null)
|
||||
{
|
||||
lock (closeGate)
|
||||
{
|
||||
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();
|
||||
CompleteClear(detached, cleared);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
cleared.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(drain, clear).ConfigureAwait(false);
|
||||
source?.CloseUnused();
|
||||
leaseGuard?.Dispose();
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completion.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
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,43 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVGuardPoint { Compare, ReadLocal, Update, Immediate, Sync, Lifecycle, PrefsSave }
|
||||
internal sealed class PlayerKVLoadSideEffectException : InvalidOperationException
|
||||
{ internal PlayerKVLoadSideEffectException() : base("KV controlled load rejected a legacy side effect.") { } }
|
||||
internal sealed class PlayerKVGameLoadGuard : IDisposable
|
||||
{
|
||||
static readonly object gate=new object();
|
||||
static PlayerKVGameLoadGuard owner;
|
||||
readonly int[] counts=new int[7];
|
||||
int violations;
|
||||
bool disposed;
|
||||
private PlayerKVGameLoadGuard() { }
|
||||
internal static bool TryEnter(out PlayerKVGameLoadGuard lease)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
lease=null; if(owner!=null) return false;
|
||||
owner=lease=new PlayerKVGameLoadGuard(); return true;
|
||||
}
|
||||
}
|
||||
internal bool IsCurrent { get { lock(gate) return !disposed && ReferenceEquals(owner,this); } }
|
||||
internal int Violations => Volatile.Read(ref violations);
|
||||
internal int Count(PlayerKVGuardPoint point) => Volatile.Read(ref counts[(int)point]);
|
||||
internal static bool Reject(PlayerKVGuardPoint point)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
if(owner==null) return false;
|
||||
Interlocked.Increment(ref owner.counts[(int)point]); Interlocked.Increment(ref owner.violations); return true;
|
||||
}
|
||||
}
|
||||
internal static void RequireLegacyAllowed(PlayerKVGuardPoint point)
|
||||
{ if(Reject(point)) throw new PlayerKVLoadSideEffectException(); }
|
||||
public void Dispose()
|
||||
{
|
||||
lock(gate) { if(disposed) return; disposed=true; if(ReferenceEquals(owner,this)) owner=null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90067312ead44486b7d1c9694e2a0814
|
||||
@@ -1,322 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVGameOpenError { None, OwnerRejected, Cancelled, Stale, CloseFailed }
|
||||
|
||||
public sealed class PlayerKVGameOpenResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVGameOpenError.None;
|
||||
public PlayerKVGameOpenError Error { get; }
|
||||
public PlayerKVSessionOpenError OwnerError { get; }
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVGameSessionHandle Handle { get; }
|
||||
internal PlayerKVGameOpenResult(PlayerKVGameOpenError error,
|
||||
PlayerKVSessionOpenError ownerError = PlayerKVSessionOpenError.None,
|
||||
PlayerKVError transportError = PlayerKVError.None, PlayerKVGameSessionHandle handle = null)
|
||||
{ Error = error; OwnerError = ownerError; TransportError = transportError; Handle = handle; }
|
||||
}
|
||||
|
||||
/// <summary>Owns the KV owner, but never disposes the supplied login session.</summary>
|
||||
public sealed class PlayerKVGameSessionBridge
|
||||
{
|
||||
// A fixed slot is also the handle's identity. It can never be rebound to another owner.
|
||||
internal sealed class Slot
|
||||
{
|
||||
internal readonly CancellationTokenSource Lifetime;
|
||||
internal readonly Func<long, bool> ExternalCurrent;
|
||||
internal readonly long Epoch;
|
||||
readonly S101AccountSession session; readonly CookieContainer cookies;
|
||||
internal PlayerKVSessionOwner Owner;
|
||||
internal Task<PlayerKVSessionOpenResult> Creation;
|
||||
internal Task Closing;
|
||||
readonly object cancellationGate = new object();
|
||||
bool lifetimeDisposed; Exception cancellationFailure;
|
||||
internal Exception CancellationFailure { get { lock (cancellationGate) return cancellationFailure; } }
|
||||
int revoked;
|
||||
internal Slot(S101AccountSession session, long epoch, Func<long, bool> current, CancellationToken token)
|
||||
{ this.session = session; cookies = session?.Cookies; Epoch = epoch; ExternalCurrent = current; Lifetime = CancellationTokenSource.CreateLinkedTokenSource(token); }
|
||||
internal void DisposeLifetime()
|
||||
{
|
||||
lock (cancellationGate)
|
||||
{
|
||||
if (lifetimeDisposed) return;
|
||||
Lifetime.Dispose(); lifetimeDisposed = true;
|
||||
}
|
||||
}
|
||||
internal bool Revoked => Volatile.Read(ref revoked) != 0;
|
||||
internal void Revoke() => Interlocked.Exchange(ref revoked, 1);
|
||||
internal bool IsCurrent(long epoch) =>
|
||||
!Revoked && !Lifetime.IsCancellationRequested && session != null && session.IsAuthenticated &&
|
||||
ReferenceEquals(session.Cookies, cookies) && epoch == Epoch && ExternalCurrent(epoch);
|
||||
internal void Cancel()
|
||||
{
|
||||
Revoke();
|
||||
// Close can revoke an opening slot while Create is completing its cleanup.
|
||||
// Serialize CTS disposal with cancellation, without holding the bridge monitor.
|
||||
lock (cancellationGate)
|
||||
{
|
||||
if (lifetimeDisposed) return;
|
||||
try { Lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class OpenRequest
|
||||
{
|
||||
internal bool Entered;
|
||||
internal CancellationTokenRegistration Registration;
|
||||
internal readonly TaskCompletionSource<PlayerKVGameOpenResult> Result =
|
||||
new TaskCompletionSource<PlayerKVGameOpenResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
readonly object gate = new object();
|
||||
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
Task tail = Task.CompletedTask;
|
||||
Task lastClose;
|
||||
Slot active;
|
||||
long closeGeneration;
|
||||
bool closeFailed;
|
||||
|
||||
public PlayerKVGameSessionBridge() : this(jar => new HttpClientHandler
|
||||
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
|
||||
|
||||
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)
|
||||
{
|
||||
if (isCurrent == null)
|
||||
return Task.FromResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected,
|
||||
PlayerKVSessionOpenError.Validation));
|
||||
var request = new OpenRequest();
|
||||
var done = NewCompletion();
|
||||
Task predecessor; long generation;
|
||||
lock (gate)
|
||||
{
|
||||
predecessor = tail; tail = done.Task;
|
||||
generation = closeGeneration;
|
||||
lastClose = null;
|
||||
}
|
||||
request.Registration = lifetimeToken.Register(() =>
|
||||
{
|
||||
lock (gate)
|
||||
if (!request.Entered)
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.Cancelled));
|
||||
});
|
||||
// The reserved tail stays pending even if the caller's queued wait is cancelled.
|
||||
_ = OpenCoreAsync(predecessor, done, request, generation, session, cacheRoot,
|
||||
playerId, api, epoch, isCurrent, lifetimeToken);
|
||||
return request.Result.Task;
|
||||
}
|
||||
|
||||
async Task OpenCoreAsync(Task predecessor, TaskCompletionSource<bool> done, OpenRequest request,
|
||||
long generation, S101AccountSession session, string cacheRoot, string playerId,
|
||||
Uri api, long epoch, Func<long, bool> isCurrent, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await predecessor.ConfigureAwait(false);
|
||||
Slot previous;
|
||||
lock (gate)
|
||||
{
|
||||
request.Entered = true;
|
||||
if (closeFailed) { Complete(request, PlayerKVGameOpenError.CloseFailed); return; }
|
||||
if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; }
|
||||
if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; }
|
||||
previous = active;
|
||||
previous?.Revoke();
|
||||
}
|
||||
if (previous != null) await CloseSlotAsync(previous).ConfigureAwait(false);
|
||||
|
||||
Slot slot;
|
||||
lock (gate)
|
||||
{
|
||||
if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; }
|
||||
if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; }
|
||||
slot = new Slot(session, epoch, isCurrent, token);
|
||||
active = slot;
|
||||
}
|
||||
slot.Creation = PlayerKVSessionOwner.CreateAsync(session, cacheRoot, playerId, api,
|
||||
epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory, prepareOriginalComparison: prepareOriginalComparison);
|
||||
var opened = await slot.Creation.ConfigureAwait(false);
|
||||
if (!opened.Success)
|
||||
{
|
||||
// CreateAsync returns failure only after its failed owner has drained.
|
||||
if (slot.CancellationFailure != null) throw slot.CancellationFailure;
|
||||
lock (gate) if (ReferenceEquals(active, slot)) active = null;
|
||||
slot.Revoke(); slot.DisposeLifetime();
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected,
|
||||
opened.Error, opened.TransportError));
|
||||
return;
|
||||
}
|
||||
slot.Owner = opened.Owner;
|
||||
var valid = slot.IsCurrent(epoch);
|
||||
lock (gate)
|
||||
{
|
||||
if (valid && generation == closeGeneration && !slot.Revoked && !token.IsCancellationRequested)
|
||||
{
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.None,
|
||||
handle: new PlayerKVGameSessionHandle(slot)));
|
||||
return;
|
||||
}
|
||||
slot.Revoke();
|
||||
}
|
||||
await CloseSlotAsync(slot).ConfigureAwait(false);
|
||||
Complete(request, token.IsCancellationRequested ? PlayerKVGameOpenError.Cancelled : PlayerKVGameOpenError.Stale);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Includes failed Create cleanup. Retain the slot, Creation/Closing task and owner;
|
||||
// no later Open is allowed to pretend the cache is reusable.
|
||||
lock (gate) { closeFailed = true; active?.Revoke(); }
|
||||
Complete(request, PlayerKVGameOpenError.CloseFailed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
request.Registration.Dispose();
|
||||
done.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Immediately revokes admission, then drains every preceding lifecycle operation.</summary>
|
||||
public Task CloseAsync()
|
||||
{
|
||||
Task predecessor; Slot observed;
|
||||
var done = NewCompletion();
|
||||
var result = NewCompletion();
|
||||
lock (gate)
|
||||
{
|
||||
if (lastClose != null) return lastClose;
|
||||
closeGeneration++;
|
||||
observed = active; observed?.Revoke();
|
||||
predecessor = tail; tail = done.Task; lastClose = result.Task;
|
||||
}
|
||||
// Cancellation callbacks must never run while the bridge monitor is held.
|
||||
observed?.Cancel();
|
||||
_ = CloseCoreAsync(predecessor, done, result);
|
||||
return result.Task;
|
||||
}
|
||||
|
||||
async Task CloseCoreAsync(Task predecessor, TaskCompletionSource<bool> done, TaskCompletionSource<bool> result)
|
||||
{
|
||||
try
|
||||
{
|
||||
await predecessor.ConfigureAwait(false);
|
||||
Slot slot;
|
||||
lock (gate)
|
||||
{
|
||||
if (closeFailed) throw new InvalidOperationException("KV owner cleanup previously failed.");
|
||||
slot = active;
|
||||
}
|
||||
if (slot != null) await CloseSlotAsync(slot).ConfigureAwait(false);
|
||||
result.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (gate) { closeFailed = true; active?.Revoke(); }
|
||||
result.TrySetException(ex);
|
||||
}
|
||||
finally { done.TrySetResult(true); }
|
||||
}
|
||||
|
||||
async Task CloseSlotAsync(Slot slot)
|
||||
{
|
||||
slot.Cancel();
|
||||
if (slot.Owner != null)
|
||||
{
|
||||
slot.Closing = slot.Closing ?? slot.Owner.CloseAsync();
|
||||
await slot.Closing.ConfigureAwait(false);
|
||||
}
|
||||
if (slot.CancellationFailure != null) throw slot.CancellationFailure;
|
||||
lock (gate) if (ReferenceEquals(active, slot)) active = null;
|
||||
slot.DisposeLifetime();
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> NewCompletion() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
static void Complete(OpenRequest request, PlayerKVGameOpenError error) =>
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(error));
|
||||
}
|
||||
|
||||
/// <summary>Captured by delayed callbacks; all operations target this original owner only.</summary>
|
||||
public sealed class PlayerKVGameSessionHandle
|
||||
{
|
||||
readonly PlayerKVGameSessionBridge.Slot slot;
|
||||
internal PlayerKVGameSessionHandle(PlayerKVGameSessionBridge.Slot slot) { this.slot = slot; }
|
||||
PlayerKVSessionOwner Owner => slot.Owner;
|
||||
public string OwnerInstanceId => Owner.OwnerInstanceId;
|
||||
public string PlayerId => Owner.PlayerId;
|
||||
public long Epoch => Owner.Epoch;
|
||||
public PlayerKVSyncStatus Status => Owner.Status;
|
||||
|
||||
public PlayerKVLocalReadResult ReadLocalSnapshot() => slot.Revoked
|
||||
? new PlayerKVLocalReadResult(PlayerKVLocalReadError.StaleOrClosing, OwnerInstanceId, PlayerId, Epoch)
|
||||
: Owner.ReadLocalSnapshot();
|
||||
|
||||
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);
|
||||
public Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token = default) =>
|
||||
!slot.IsCurrent(Epoch) ? Task.FromResult(new PlayerKVTicketResult(
|
||||
PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled, Owner.Status)) : Owner.WaitForTicketAsync(ticket, token);
|
||||
static PlayerKVEditResult BlockedEdit() =>
|
||||
new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebfda20b77ad4dc1ac25f16acffb9995
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVKeyReadError { None, InvalidInput, LoadGuardActive, StaleOrClosing, NotReady }
|
||||
|
||||
/// <summary>
|
||||
/// A detached key observation, not authority for a later gameplay mutation.
|
||||
/// Found=null means failure; Found=false means a valid snapshot lacked the key;
|
||||
/// Found=true means a value exists, including the empty string.
|
||||
/// </summary>
|
||||
internal sealed class PlayerKVKeyReadResult
|
||||
{
|
||||
internal PlayerKVKeyReadError Error { get; }
|
||||
internal bool Success => Error == PlayerKVKeyReadError.None;
|
||||
internal bool? Found { get; }
|
||||
internal string Value { get; }
|
||||
internal string OwnerInstanceId { get; }
|
||||
internal string PlayerId { get; }
|
||||
internal long? Epoch { get; }
|
||||
|
||||
internal PlayerKVKeyReadResult(PlayerKVKeyReadError error, PlayerKVGameSessionHandle handle,
|
||||
bool? found = null, string value = null)
|
||||
{
|
||||
if (error == PlayerKVKeyReadError.None && (!found.HasValue || (found.Value && value == null)))
|
||||
throw new ArgumentException("A successful key observation requires an existence verdict.");
|
||||
Error = error;
|
||||
OwnerInstanceId = handle?.OwnerInstanceId;
|
||||
PlayerId = handle?.PlayerId;
|
||||
Epoch = handle?.Epoch;
|
||||
Found = Success ? found : null;
|
||||
Value = Success && found == true ? value : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 916120c58a7f4f71bf59e17a4a045bb8
|
||||
@@ -1,30 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVLocalReadError { None, StaleOrClosing, NotReady }
|
||||
|
||||
/// <summary>A detached snapshot. Failed reads have no data, never a pretend empty save.</summary>
|
||||
public sealed class PlayerKVLocalReadResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVLocalReadError.None;
|
||||
public PlayerKVLocalReadError Error { get; }
|
||||
public string OwnerInstanceId { get; }
|
||||
public string PlayerId { get; }
|
||||
public long Epoch { get; }
|
||||
public IReadOnlyDictionary<string, string> Data { get; }
|
||||
|
||||
internal PlayerKVLocalReadResult(PlayerKVLocalReadError error, string ownerId,
|
||||
string playerId, long epoch, IReadOnlyDictionary<string, string> data = null)
|
||||
{
|
||||
Error = error; OwnerInstanceId = ownerId; PlayerId = playerId; Epoch = epoch;
|
||||
if (error != PlayerKVLocalReadError.None) return;
|
||||
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var item in data) copy.Add(item.Key, item.Value);
|
||||
Data = new ReadOnlyDictionary<string, string>(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4bbcb1b86b2b4301af3d7518408edd6f
|
||||
@@ -1,542 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns exactly one authenticated session and its KV bridge. Does not initialize gameplay or write.
|
||||
/// The caller must await the previous context's CloseAsync before creating the next context.
|
||||
/// A failed Close must never be bypassed by constructing another instance.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVLoginContext
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly string requestedId, cacheRoot;
|
||||
readonly long epoch;
|
||||
readonly Uri api;
|
||||
readonly Func<long, bool> externalCurrent;
|
||||
readonly Func<S101AccountSession> sessionFactory;
|
||||
readonly Func<PlayerKVGameSessionBridge> bridgeFactory;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
readonly TaskCompletionSource<bool> workSettled = NewSignal();
|
||||
TaskCompletionSource<PlayerKVLoginPrepareResult> preparation;
|
||||
TaskCompletionSource<bool> closing;
|
||||
CancellationToken firstToken;
|
||||
CancellationTokenRegistration registration;
|
||||
S101AccountSession session;
|
||||
CookieContainer cookies;
|
||||
PlayerKVGameSessionBridge bridge;
|
||||
PlayerKVGameSessionHandle handle;
|
||||
PlayerKVAccountDescriptor accountDescriptor;
|
||||
PlayerKVAccountLease accountLease;
|
||||
bool leaseIssued;
|
||||
string playerId, predicateExceptionType;
|
||||
int revoked, prepared, differentTokenCount;
|
||||
|
||||
// Internal factories are only for synthetic transport/cleanup faults. No public session or handle escapes.
|
||||
public PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent)
|
||||
: this(playerId, cacheRoot, epoch, isCurrent, new Uri(S101AccountSession.DefaultApiUrl),
|
||||
() => new S101AccountSession(), () => new PlayerKVGameSessionBridge()) { }
|
||||
|
||||
internal PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent,
|
||||
Uri api, Func<S101AccountSession> sessionFactory, Func<PlayerKVGameSessionBridge> bridgeFactory)
|
||||
{
|
||||
requestedId = playerId; this.cacheRoot = cacheRoot; this.epoch = epoch; externalCurrent = isCurrent;
|
||||
this.api = api; this.sessionFactory = sessionFactory; this.bridgeFactory = bridgeFactory;
|
||||
}
|
||||
|
||||
internal int DifferentTokenCount => Volatile.Read(ref differentTokenCount);
|
||||
|
||||
internal PlayerKVAccountDescriptor AccountDescriptor =>
|
||||
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
|
||||
|
||||
// Only the coordinator may publish this original handle after preparation and Guard release.
|
||||
internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle)
|
||||
{
|
||||
gameHandle = null;
|
||||
// IsCurrent can invoke external code and synchronously reenter CloseAsync.
|
||||
if (expectedEpoch != epoch || !IsCurrent) return false;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || revoked != 0 || prepared != 1 || handle == null || handle.Epoch != expectedEpoch)
|
||||
return false;
|
||||
gameHandle = handle;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// All issuance and Close admission share gate. The editor factory may not block on Close.
|
||||
internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability,
|
||||
out PlayerKVAccountLease lease)
|
||||
{
|
||||
lease = null;
|
||||
if (capability == null || !IsCurrent) return false;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || revoked != 0 || prepared == 0 || leaseIssued) return false;
|
||||
leaseIssued = true;
|
||||
accountLease = new PlayerKVAccountLease(api, epoch, cookies, capability, () => IsCurrent,
|
||||
() => Volatile.Read(ref revoked) == 0);
|
||||
// A synthetic factory can synchronously revoke this context while being invoked.
|
||||
if (revoked != 0 || closing != null) return false;
|
||||
lease = accountLease;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Historical Prepared results and their data survive Close, but their authority does not.</summary>
|
||||
public bool IsCurrent
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Volatile.Read(ref prepared) == 0 || !EpochCurrent(epoch)) return false;
|
||||
var observed = session;
|
||||
return observed != null && observed.IsAuthenticated && observed.PlayerId == playerId &&
|
||||
ReferenceEquals(observed.Cookies, cookies) && Volatile.Read(ref revoked) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One task per instance. Only the FIRST token owns the lifetime; a different token on later calls
|
||||
/// is diagnosed but never replaces it. Even after Close this returns the original historical task.
|
||||
/// Cancellation/failure does not complete until transport and Close have really drained. A transport
|
||||
/// ignoring cancellation can hold this task indefinitely; observe CloseAsync, never bypass its barrier.
|
||||
/// </summary>
|
||||
public Task<PlayerKVLoginPrepareResult> PrepareAsync(CancellationToken lifetimeToken = default)
|
||||
{
|
||||
Task<PlayerKVLoginPrepareResult> result;
|
||||
bool start, alreadyClosing;
|
||||
lock (gate)
|
||||
{
|
||||
start = preparation == null;
|
||||
if (start)
|
||||
{
|
||||
preparation = new TaskCompletionSource<PlayerKVLoginPrepareResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
firstToken = lifetimeToken;
|
||||
}
|
||||
else if (firstToken != lifetimeToken) Interlocked.Increment(ref differentTokenCount);
|
||||
result = preparation.Task;
|
||||
alreadyClosing = closing != null;
|
||||
}
|
||||
if (!start)
|
||||
{
|
||||
if (firstToken != lifetimeToken) Debug.WriteLine("KV PrepareAsync ignored a different lifetime token; the first token still owns this context.");
|
||||
return result;
|
||||
}
|
||||
if (alreadyClosing) _ = CompleteFailureAsync(new Attempt(), PlayerKVLoginPrepareError.Cancelled);
|
||||
else _ = PrepareCoreAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
sealed class Attempt
|
||||
{
|
||||
internal AccountLoginError? AccountError;
|
||||
internal int? HttpStatus;
|
||||
internal PlayerKVGameOpenError? OpenError;
|
||||
internal PlayerKVSessionOpenError? OwnerError;
|
||||
internal PlayerKVError? KVError;
|
||||
internal PlayerKVSyncStatus? SyncStatus;
|
||||
internal string OwnerId, ExceptionType;
|
||||
}
|
||||
|
||||
async Task PrepareCoreAsync()
|
||||
{
|
||||
var evidence = new Attempt();
|
||||
var error = PlayerKVLoginPrepareError.Unexpected;
|
||||
try
|
||||
{
|
||||
registration = firstToken.Register(() => { _ = CloseAsync(); });
|
||||
if (!Validate()) error = PlayerKVLoginPrepareError.Validation;
|
||||
else if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else
|
||||
{
|
||||
session = sessionFactory() ?? throw new InvalidOperationException("Session factory returned null.");
|
||||
if (session.ApiBaseUri != api) error = PlayerKVLoginPrepareError.Validation;
|
||||
else
|
||||
{
|
||||
var auth = await session.LoginAsync(playerId, lifetime.Token).ConfigureAwait(false);
|
||||
evidence.AccountError = auth.Error; evidence.HttpStatus = auth.StatusCode;
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!auth.Success) error = PlayerKVLoginPrepareError.AuthenticationFailed;
|
||||
else
|
||||
{
|
||||
cookies = session.Cookies;
|
||||
bridge = bridgeFactory() ?? throw new InvalidOperationException("Bridge factory returned null.");
|
||||
var opened = await bridge.OpenAsync(session, cacheRoot, playerId, api, epoch,
|
||||
EpochCurrent, lifetime.Token).ConfigureAwait(false);
|
||||
evidence.OpenError = opened.Error;
|
||||
if (opened.Error == PlayerKVGameOpenError.OwnerRejected)
|
||||
{ evidence.OwnerError = opened.OwnerError; evidence.KVError = opened.TransportError; }
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!opened.Success) error = PlayerKVLoginPrepareError.OwnerRejected;
|
||||
else
|
||||
{
|
||||
handle = opened.Handle;
|
||||
evidence.OwnerId = handle.OwnerInstanceId;
|
||||
var read = handle.ReadLocalSnapshot();
|
||||
evidence.SyncStatus = handle.Status;
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!read.Success) error = read.Error == PlayerKVLocalReadError.StaleOrClosing
|
||||
? PlayerKVLoginPrepareError.Stale : PlayerKVLoginPrepareError.NotReady;
|
||||
else
|
||||
{
|
||||
var success = Result(evidence, PlayerKVLoginPrepareError.None, read.Data);
|
||||
// External callback is never invoked under our monitor. Close/reentry is rechecked at publication.
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else lock (gate)
|
||||
{
|
||||
if (revoked == 0)
|
||||
{
|
||||
accountDescriptor = new PlayerKVAccountDescriptor(session.PlayerId,
|
||||
epoch, session.InternalId, session.NewlyCreated,
|
||||
session.CreateTimeUtc, session.ServerTimeUtc);
|
||||
Volatile.Write(ref prepared, 1);
|
||||
preparation.TrySetResult(success);
|
||||
return;
|
||||
}
|
||||
error = PlayerKVLoginPrepareError.Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
evidence.ExceptionType = ex.GetType().Name;
|
||||
error = Volatile.Read(ref revoked) != 0 ? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Unexpected;
|
||||
}
|
||||
finally { workSettled.TrySetResult(true); }
|
||||
evidence.ExceptionType = evidence.ExceptionType ?? Volatile.Read(ref predicateExceptionType);
|
||||
await CompleteFailureAsync(evidence, error).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bool Validate()
|
||||
{
|
||||
if (epoch < 0 || externalCurrent == null || sessionFactory == null || bridgeFactory == null ||
|
||||
string.IsNullOrWhiteSpace(cacheRoot) || !Path.IsPathFullyQualified(cacheRoot) ||
|
||||
!long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0 ||
|
||||
api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") ||
|
||||
api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0) return false;
|
||||
try { Path.GetFullPath(cacheRoot); }
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) { return false; }
|
||||
playerId = id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EpochCurrent(long observedEpoch)
|
||||
{
|
||||
if (Volatile.Read(ref revoked) != 0 || observedEpoch != epoch) return false;
|
||||
try { return externalCurrent != null && externalCurrent(epoch) && Volatile.Read(ref revoked) == 0; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Interlocked.CompareExchange(ref predicateExceptionType, ex.GetType().Name, null);
|
||||
_ = CloseAsync();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerKVLoginPrepareError InactiveError() => Volatile.Read(ref predicateExceptionType) != null
|
||||
? PlayerKVLoginPrepareError.Unexpected : Volatile.Read(ref revoked) != 0
|
||||
? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Stale;
|
||||
|
||||
PlayerKVLoginPrepareResult Result(Attempt e, PlayerKVLoginPrepareError error,
|
||||
System.Collections.Generic.IReadOnlyDictionary<string, string> data = null,
|
||||
string cleanupException = null, PlayerKVLoginPrepareError? original = null) =>
|
||||
new PlayerKVLoginPrepareResult(error, playerId, epoch, e.AccountError, e.HttpStatus,
|
||||
e.OpenError, e.OwnerError, e.KVError, e.SyncStatus, e.OwnerId, data,
|
||||
e.ExceptionType, cleanupException, original);
|
||||
|
||||
async Task CompleteFailureAsync(Attempt evidence, PlayerKVLoginPrepareError error)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
preparation.TrySetResult(Result(evidence, error));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
preparation.TrySetResult(Result(evidence, PlayerKVLoginPrepareError.CloseFailed,
|
||||
cleanupException: ex.GetType().Name, original: error));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Synchronously revokes admission; drains preparation and bridge before disposing the session.</summary>
|
||||
public Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null) return closing.Task;
|
||||
Volatile.Write(ref revoked, 1);
|
||||
completion = closing = NewSignal();
|
||||
if (preparation == null) workSettled.TrySetResult(true);
|
||||
}
|
||||
// A cancellation registration must not execute disposal of itself or user cancellation callbacks inline.
|
||||
// Observe faults without changing the shared task, even when cancellation initiates a background Close.
|
||||
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = Task.Run(() => CloseCoreAsync(completion));
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
async Task CloseCoreAsync(TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
Exception cancellationFailure = null;
|
||||
try { lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
await workSettled.Task.ConfigureAwait(false);
|
||||
PlayerKVAccountLease borrowed;
|
||||
lock (gate) borrowed = accountLease;
|
||||
if (borrowed != null) await borrowed.CloseAsync().ConfigureAwait(false);
|
||||
if (bridge != null) await bridge.CloseAsync().ConfigureAwait(false);
|
||||
// Never dispose the session on a failed bridge close. References and the faulted task stay retained.
|
||||
session?.Dispose();
|
||||
registration.Dispose();
|
||||
lifetime.Dispose();
|
||||
if (cancellationFailure != null) throw cancellationFailure;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> NewSignal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
// Kept with the context so its existing explicitly linked regression host retains all dependencies.
|
||||
internal sealed class PlayerKVAccountDescriptor
|
||||
{
|
||||
internal string PlayerId { get; }
|
||||
internal long Epoch { get; }
|
||||
internal uint InternalId { get; }
|
||||
internal bool NewlyCreated { get; }
|
||||
internal DateTime CreateTimeUtc { get; }
|
||||
internal DateTime ServerTimeUtc { get; }
|
||||
internal PlayerKVAccountDescriptor(string playerId, long epoch, uint internalId,
|
||||
bool newlyCreated, DateTime createTimeUtc, DateTime serverTimeUtc)
|
||||
{
|
||||
PlayerId = playerId; Epoch = epoch; InternalId = internalId; NewlyCreated = newlyCreated;
|
||||
CreateTimeUtc = createTimeUtc; ServerTimeUtc = serverTimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Controlled transport factory; formal and Editor validation modes remain separate.</summary>
|
||||
internal sealed class PlayerKVControlledLoadCapability
|
||||
{
|
||||
readonly Func<CookieContainer, HttpMessageHandler> leaseHandler;
|
||||
readonly bool formal;
|
||||
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler, bool formal)
|
||||
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); this.formal = formal; }
|
||||
internal bool IsFormal => formal;
|
||||
|
||||
internal static PlayerKVControlledLoadCapability CreateForGame(
|
||||
Func<CookieContainer, HttpMessageHandler> handler) => new PlayerKVControlledLoadCapability(handler, true);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVControlledLoadCapability CreateForValidation(
|
||||
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler, false);
|
||||
#endif
|
||||
|
||||
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// 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"))
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Real or cyclic transport is not a validation handler.");
|
||||
}
|
||||
if (formal && node is HttpClientHandler real &&
|
||||
(!real.UseCookies || !ReferenceEquals(real.CookieContainer, cookies) || real.AllowAutoRedirect))
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Formal transport must share the session cookies and reject redirects.");
|
||||
}
|
||||
if (formal && node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Unverified formal transport handler.");
|
||||
}
|
||||
node = (node as DelegatingHandler)?.InnerHandler;
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVAccountResponse
|
||||
{
|
||||
internal bool IsCurrent { get; }
|
||||
internal int? StatusCode { get; }
|
||||
internal string Body { get; }
|
||||
internal PlayerKVAccountResponse(bool isCurrent, int? statusCode = null, string body = null)
|
||||
{ IsCurrent = isCurrent; StatusCode = isCurrent ? statusCode : null; Body = isCurrent ? body : null; }
|
||||
}
|
||||
|
||||
/// <summary>A single context-owned, synthetic-only borrower. Close drains before disposing its client.</summary>
|
||||
internal sealed class PlayerKVAccountLease
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly HttpClient client;
|
||||
readonly CookieContainer cookies;
|
||||
readonly bool formal;
|
||||
readonly Func<bool> current;
|
||||
readonly Func<bool> admission;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
TaskCompletionSource<bool> drained;
|
||||
TaskCompletionSource<bool> closing;
|
||||
int active;
|
||||
internal Uri ApiBaseUri { get; }
|
||||
internal long Epoch { get; }
|
||||
internal bool IsFormal => formal;
|
||||
internal CookieContainer FormalCookies => formal && IsCurrent ? cookies : null;
|
||||
internal bool IsCurrent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate) if (closing != null) return false;
|
||||
return current();
|
||||
}
|
||||
}
|
||||
|
||||
internal PlayerKVAccountLease(Uri api, long epoch, CookieContainer cookies,
|
||||
PlayerKVControlledLoadCapability capability, Func<bool> current, Func<bool> admission)
|
||||
{
|
||||
if (capability == null) throw new ArgumentNullException(nameof(capability));
|
||||
ApiBaseUri = api; Epoch = epoch; this.cookies = cookies;
|
||||
formal = capability.IsFormal; this.current = current; this.admission = admission;
|
||||
client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api };
|
||||
if (formal) client.Timeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVAccountResponse> SendAsync(HttpMethod method, string route,
|
||||
string body = null, string contentType = "application/json", CancellationToken token = default)
|
||||
{
|
||||
if (formal)
|
||||
{
|
||||
if (!IsCanonicalBusinessRoute(route))
|
||||
throw new ArgumentException("Only canonical relative business routes are allowed.", nameof(route));
|
||||
}
|
||||
else if (route != "__kv_validation/ok" && route != "__kv_validation/unauthorized")
|
||||
throw new ArgumentException("Only canonical validation routes are allowed.", nameof(route));
|
||||
if (method != HttpMethod.Get && method != HttpMethod.Post)
|
||||
throw new ArgumentException("Unsupported lease method.", nameof(method));
|
||||
if (!IsCurrent) return new PlayerKVAccountResponse(false);
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || !admission()) return new PlayerKVAccountResponse(false);
|
||||
active++;
|
||||
}
|
||||
try
|
||||
{
|
||||
using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, lifetime.Token))
|
||||
using (var request = new HttpRequestMessage(method, route))
|
||||
{
|
||||
if (body != null) request.Content = new StringContent(body, System.Text.Encoding.UTF8, contentType);
|
||||
using (var response = await client.SendAsync(request, linked.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (!IsCurrent || linked.IsCancellationRequested) return new PlayerKVAccountResponse(false);
|
||||
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
// No redirect following, even if a synthetic handler returns a Location header.
|
||||
if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400)
|
||||
throw new InvalidOperationException("Redirects are not accepted.");
|
||||
return IsCurrent && !linked.IsCancellationRequested
|
||||
? new PlayerKVAccountResponse(true, (int)response.StatusCode, text)
|
||||
: new PlayerKVAccountResponse(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!IsCurrent || token.IsCancellationRequested || lifetime.IsCancellationRequested)
|
||||
{ return new PlayerKVAccountResponse(false); }
|
||||
finally
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
active--;
|
||||
if (active == 0) drained?.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsCanonicalBusinessRoute(string route)
|
||||
{
|
||||
if (string.IsNullOrEmpty(route) || route[0] == '/' || route[0] == '\\' ||
|
||||
Uri.TryCreate(route, UriKind.Absolute, out _) ||
|
||||
route.IndexOf("//", StringComparison.Ordinal) >= 0 ||
|
||||
route.IndexOfAny(new[] { '?', '#', '%', '\\' }) >= 0)
|
||||
return false;
|
||||
var segments = route.Split('/');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment.Length == 0 || segment == "." || segment == "..") return false;
|
||||
foreach (var character in segment)
|
||||
if (char.IsControl(character)) return false;
|
||||
}
|
||||
return !route.Equals("__kv_validation", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.StartsWith("__kv_validation/", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.Equals("account/Login", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.Equals("playerdata", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.StartsWith("playerdata/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null) return closing.Task;
|
||||
completion = closing = Signal();
|
||||
drained = Signal();
|
||||
if (active == 0) drained.TrySetResult(true);
|
||||
}
|
||||
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Exception cancellationFailure = null;
|
||||
try { lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
await drained.Task.ConfigureAwait(false);
|
||||
client.Dispose();
|
||||
lifetime.Dispose();
|
||||
if (cancellationFailure != null) throw cancellationFailure;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
});
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> Signal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVLoginPrepareError
|
||||
{
|
||||
None, Validation, Cancelled, Stale, AuthenticationFailed, OwnerRejected, NotReady, Unexpected, CloseFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historical outcome of one preparation, not permission to initialize gameplay or create a save.
|
||||
/// Check the context's IsCurrent before using a prepared snapshot. Failure/cancellation waits for
|
||||
/// actual transport and Close completion; a transport ignoring cancellation may hold it indefinitely.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVLoginPrepareResult
|
||||
{
|
||||
public bool Prepared => Error == PlayerKVLoginPrepareError.None;
|
||||
public PlayerKVLoginPrepareError Error { get; }
|
||||
public PlayerKVLoginPrepareError OriginalError { get; }
|
||||
public bool CleanupFailed => Error == PlayerKVLoginPrepareError.CloseFailed;
|
||||
public AccountLoginError? AccountError { get; }
|
||||
public PlayerKVGameOpenError? OpenError { get; }
|
||||
public PlayerKVSessionOpenError? OwnerError { get; }
|
||||
public PlayerKVError? KVError { get; }
|
||||
public PlayerKVSyncStatus? SyncStatus { get; }
|
||||
public int? HttpStatusCode { get; }
|
||||
public string PlayerId { get; }
|
||||
public long Epoch { get; }
|
||||
public string OwnerInstanceId { get; }
|
||||
public string ExceptionType { get; }
|
||||
public string CleanupExceptionType { get; }
|
||||
public IReadOnlyDictionary<string, string> Data { get; }
|
||||
public bool IsEmpty => Prepared && Data.Count == 0;
|
||||
|
||||
internal PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError error, string playerId, long epoch,
|
||||
AccountLoginError? accountError = null, int? httpStatus = null,
|
||||
PlayerKVGameOpenError? openError = null, PlayerKVSessionOpenError? ownerError = null,
|
||||
PlayerKVError? kvError = null, PlayerKVSyncStatus? syncStatus = null,
|
||||
string ownerId = null, IReadOnlyDictionary<string, string> data = null,
|
||||
string exceptionType = null, string cleanupExceptionType = null,
|
||||
PlayerKVLoginPrepareError? originalError = null)
|
||||
{
|
||||
Error = error; OriginalError = originalError ?? error; PlayerId = playerId; Epoch = epoch;
|
||||
AccountError = accountError; HttpStatusCode = httpStatus; OpenError = openError;
|
||||
OwnerError = ownerError; KVError = kvError; SyncStatus = syncStatus; OwnerInstanceId = ownerId;
|
||||
ExceptionType = exceptionType; CleanupExceptionType = cleanupExceptionType;
|
||||
if (!Prepared) return;
|
||||
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in data) copy.Add(pair.Key, pair.Value);
|
||||
Data = new ReadOnlyDictionary<string, string>(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2c0df39903a45f780cc6751d1774c51
|
||||
@@ -1,39 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVError
|
||||
{
|
||||
None, Validation, Unauthorized, Http, Network, Timeout, Cancelled, Protocol
|
||||
}
|
||||
|
||||
public sealed class PlayerKVResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVError.None;
|
||||
public PlayerKVError Error { get; }
|
||||
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, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9401473b45424dbf94ddece933db1c86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,43 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Immutable process-start selection. Accounts 101 and 102 are the reviewed first cohort;
|
||||
/// expanding it requires a new complete migration design review.
|
||||
/// </summary>
|
||||
internal sealed class PlayerKVRuntimeSelection
|
||||
{
|
||||
internal const string FormalApiUrl = "http://s101.local:18080/";
|
||||
|
||||
internal bool IsKV { get; }
|
||||
internal Uri Api { get; }
|
||||
|
||||
private PlayerKVRuntimeSelection(bool isKV, Uri api)
|
||||
{
|
||||
IsKV = isKV;
|
||||
Api = api;
|
||||
}
|
||||
|
||||
internal static PlayerKVRuntimeSelection Capture(string backend, string api, bool agg)
|
||||
{
|
||||
if (backend == null || backend == "playfab")
|
||||
return new PlayerKVRuntimeSelection(false, null);
|
||||
if (backend != "kv")
|
||||
throw new ArgumentException("Unsupported save backend.", nameof(backend));
|
||||
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));
|
||||
}
|
||||
|
||||
internal bool TryAdmitPlayer(string requestedId, out string canonicalId)
|
||||
{
|
||||
canonicalId = null;
|
||||
if (!IsKV || !long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) ||
|
||||
(id != 101 && id != 102)) return false;
|
||||
canonicalId = id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVSessionOpenError
|
||||
{
|
||||
None, Validation, Unauthenticated, Busy, RecoveryRequired, StorageError,
|
||||
ReadFailed, Unauthorized, Stale, Cancelled
|
||||
}
|
||||
|
||||
public sealed class PlayerKVSessionOpenResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVSessionOpenError.None;
|
||||
public PlayerKVSessionOpenError Error { get; }
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVSessionOwner Owner { get; }
|
||||
internal PlayerKVSessionOpenResult(PlayerKVSessionOpenError error,
|
||||
PlayerKVError transportError = PlayerKVError.None, PlayerKVSessionOwner owner = null)
|
||||
{ Error = error; TransportError = transportError; Owner = owner; }
|
||||
}
|
||||
|
||||
/// <summary>Owns one immutable account/epoch/cache/transport tuple.</summary>
|
||||
public sealed class PlayerKVSessionOwner : IDisposable
|
||||
{
|
||||
sealed class OperationLease : IDisposable
|
||||
{
|
||||
PlayerKVSessionOwner owner;
|
||||
internal OperationLease(PlayerKVSessionOwner owner) { this.owner = owner; }
|
||||
public void Dispose() { Interlocked.Exchange(ref owner, null)?.ExitOperation(); }
|
||||
}
|
||||
|
||||
readonly object admission = new object();
|
||||
readonly S101AccountSession session;
|
||||
readonly CookieContainer cookies;
|
||||
readonly CancellationTokenSource lifetime;
|
||||
readonly Func<long, bool> isCurrent;
|
||||
readonly PlayerKVSyncState state;
|
||||
readonly PlayerKVSyncCoordinator coordinator;
|
||||
readonly TaskCompletionSource<bool> drained =
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
readonly string ownerId = Guid.NewGuid().ToString("N");
|
||||
int activeOperations;
|
||||
bool closing, closed;
|
||||
TaskCompletionSource<bool> closeCompletion;
|
||||
|
||||
public string OwnerInstanceId => ownerId;
|
||||
public Uri ApiBaseUri { get; }
|
||||
public string PlayerId { get; }
|
||||
public long Epoch { get; }
|
||||
public PlayerKVSyncStatus Status => state.Status;
|
||||
|
||||
PlayerKVSessionOwner(S101AccountSession session, CookieContainer cookies, Uri api, string playerId,
|
||||
long epoch, Func<long, bool> isCurrent, CancellationTokenSource lifetime,
|
||||
PlayerKVSyncState state, HttpClient http)
|
||||
{
|
||||
this.session = session; this.cookies = cookies; ApiBaseUri = api; PlayerId = playerId;
|
||||
Epoch = epoch; this.isCurrent = isCurrent; this.lifetime = lifetime; this.state = state;
|
||||
coordinator = new PlayerKVSyncCoordinator(state, new PlayerKVClient(http, api), http,
|
||||
ownerId, epoch, IsCapturedOwnerCurrent, lifetime.Token);
|
||||
}
|
||||
|
||||
public static Task<PlayerKVSessionOpenResult> CreateAsync(S101AccountSession session, string cacheRoot,
|
||||
string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func<long, bool> isCurrent,
|
||||
CancellationToken lifetimeToken = default) =>
|
||||
CreateAsync(session, cacheRoot, expectedPlayerId, expectedApiBaseUri, epoch, isCurrent,
|
||||
lifetimeToken, jar => new HttpClientHandler
|
||||
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false });
|
||||
|
||||
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, 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)
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation);
|
||||
var playerId = numericId.ToString(CultureInfo.InvariantCulture);
|
||||
Uri expectedApi;
|
||||
try { expectedApi = NormalizeApi(expectedApiBaseUri); }
|
||||
catch (ArgumentException) { return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation); }
|
||||
if (lifetimeToken.IsCancellationRequested)
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled);
|
||||
if (!session.IsAuthenticated || session.Cookies == null)
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Unauthenticated);
|
||||
var capturedCookies = session.Cookies;
|
||||
var capturedApi = NormalizeApi(session.ApiBaseUri);
|
||||
if (session.PlayerId != playerId || capturedApi != expectedApi || !isCurrent(epoch))
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Stale);
|
||||
|
||||
var opened = PlayerKVSyncState.Open(cacheRoot, capturedApi, playerId);
|
||||
if (!opened.Success) return new PlayerKVSessionOpenResult(MapOpen(opened.Error));
|
||||
HttpClient http = null;
|
||||
CancellationTokenSource lifetime = null;
|
||||
PlayerKVSessionOwner owner = null;
|
||||
try
|
||||
{
|
||||
var handler = handlerFactory(capturedCookies);
|
||||
if (handler == null) throw new ArgumentException("Transport factory returned null.");
|
||||
http = new HttpClient(handler) { Timeout = transportTimeout ?? TimeSpan.FromSeconds(15) };
|
||||
lifetime = CancellationTokenSource.CreateLinkedTokenSource(lifetimeToken);
|
||||
owner = new PlayerKVSessionOwner(session, capturedCookies, capturedApi, playerId, epoch,
|
||||
isCurrent, lifetime, opened.State, http);
|
||||
http = null; lifetime = null;
|
||||
PlayerKVResult read;
|
||||
using (var lease = owner.TryEnter())
|
||||
{
|
||||
if (lease == null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled);
|
||||
read = await owner.coordinator.InitializeAsync(prepareOriginalComparison);
|
||||
}
|
||||
if (!read.Success)
|
||||
return await owner.FailCreate(read.Error == PlayerKVError.Unauthorized
|
||||
? PlayerKVSessionOpenError.Unauthorized
|
||||
: read.Error == PlayerKVError.Cancelled ? PlayerKVSessionOpenError.Cancelled
|
||||
: PlayerKVSessionOpenError.ReadFailed, read.Error);
|
||||
if (!owner.IsCapturedOwnerCurrent())
|
||||
return await owner.FailCreate(PlayerKVSessionOpenError.Stale, PlayerKVError.Cancelled);
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.None, owner: owner);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (owner != null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled);
|
||||
opened.State.Dispose();
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
if (owner != null) return await owner.FailCreate(PlayerKVSessionOpenError.Validation, PlayerKVError.Protocol);
|
||||
http?.Dispose(); lifetime?.Dispose(); opened.State.Dispose();
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation, PlayerKVError.Protocol);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the existing state under an operation lease; does not open another cache.</summary>
|
||||
public PlayerKVLocalReadResult ReadLocalSnapshot()
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
{
|
||||
if (lease == null) return ReadFailure(PlayerKVLocalReadError.StaleOrClosing);
|
||||
try
|
||||
{
|
||||
var data = state.ReadLocal();
|
||||
lock (admission)
|
||||
{
|
||||
if (!IsCapturedOwnerCurrent() || closing || closed)
|
||||
return ReadFailure(PlayerKVLocalReadError.StaleOrClosing);
|
||||
return new PlayerKVLocalReadResult(PlayerKVLocalReadError.None,
|
||||
ownerId, PlayerId, Epoch, data);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
lock (admission)
|
||||
return ReadFailure(!IsCapturedOwnerCurrent() || closing || closed
|
||||
? PlayerKVLocalReadError.StaleOrClosing : PlayerKVLocalReadError.NotReady);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ? 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)
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
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();
|
||||
if (lease == null) return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0,
|
||||
PlayerKVError.Cancelled, state.Status));
|
||||
return WaitForFlushAsync(RunFlushAsync(lease), waitToken);
|
||||
}
|
||||
|
||||
/// <summary>Runs a complete recovery Get on the same serialized channel and fixed owner transport.</summary>
|
||||
public Task<PlayerKVResult> RefreshAsync(CancellationToken waitToken = default)
|
||||
{
|
||||
var lease = TryEnter();
|
||||
if (lease == null) return Task.FromResult(new PlayerKVResult(PlayerKVError.Cancelled));
|
||||
var shared = RunRefreshAsync(lease);
|
||||
return WaitForRefreshAsync(shared, waitToken);
|
||||
}
|
||||
|
||||
public Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket,
|
||||
CancellationToken cancellationToken = default) => coordinator.WaitForTicketAsync(ticket, cancellationToken);
|
||||
|
||||
// Watermark capture must happen inside the lease scope: a failure there still
|
||||
// returns the operation lease, or CloseAsync would wait for this operation forever.
|
||||
async Task<PlayerKVFlushResult> RunFlushAsync(OperationLease lease)
|
||||
{
|
||||
using (lease)
|
||||
{
|
||||
try
|
||||
{
|
||||
var watermark = coordinator.CaptureWatermark(out var targetSequences);
|
||||
return await coordinator.FlushThroughAsync(watermark, targetSequences);
|
||||
}
|
||||
catch (Exception) { return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Protocol, state.Status); }
|
||||
}
|
||||
}
|
||||
|
||||
async Task<PlayerKVResult> RunRefreshAsync(OperationLease lease)
|
||||
{
|
||||
using (lease)
|
||||
{
|
||||
try { return await coordinator.InitializeAsync(); }
|
||||
catch (OperationCanceledException) { return new PlayerKVResult(PlayerKVError.Cancelled); }
|
||||
catch (ObjectDisposedException) { return new PlayerKVResult(PlayerKVError.Cancelled); }
|
||||
catch (HttpRequestException) { return new PlayerKVResult(PlayerKVError.Network); }
|
||||
catch (IOException) { return new PlayerKVResult(PlayerKVError.Network); }
|
||||
catch (Exception) { return new PlayerKVResult(PlayerKVError.Protocol); }
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<PlayerKVResult> WaitForRefreshAsync(Task<PlayerKVResult> shared, CancellationToken token)
|
||||
{
|
||||
if (!token.CanBeCanceled) return await shared;
|
||||
if (token.IsCancellationRequested) return new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (token.Register(() => cancelled.TrySetResult(true)))
|
||||
{
|
||||
if (await Task.WhenAny(shared, cancelled.Task) == shared) return await shared;
|
||||
}
|
||||
return new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
}
|
||||
|
||||
async Task<PlayerKVFlushResult> WaitForFlushAsync(Task<PlayerKVFlushResult> shared, CancellationToken token)
|
||||
{
|
||||
if (!token.CanBeCanceled) return await shared;
|
||||
if (token.IsCancellationRequested) return new PlayerKVFlushResult(0, 0, 1, 0,
|
||||
PlayerKVError.Cancelled, state.Status);
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (token.Register(() => cancelled.TrySetResult(true)))
|
||||
{
|
||||
if (await Task.WhenAny(shared, cancelled.Task) == shared) return await shared;
|
||||
}
|
||||
return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status);
|
||||
}
|
||||
|
||||
OperationLease TryEnter()
|
||||
{
|
||||
lock (admission)
|
||||
{
|
||||
if (closing || closed || !IsCapturedOwnerCurrent()) return null;
|
||||
activeOperations++;
|
||||
return new OperationLease(this);
|
||||
}
|
||||
}
|
||||
|
||||
void ExitOperation()
|
||||
{
|
||||
lock (admission)
|
||||
{
|
||||
activeOperations--;
|
||||
if (closing && activeOperations == 0) drained.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (admission)
|
||||
{
|
||||
if (closeCompletion != null) return closeCompletion.Task;
|
||||
closing = true;
|
||||
completion = closeCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (activeOperations == 0) drained.TrySetResult(true);
|
||||
}
|
||||
_ = CloseCoreAsync(completion);
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
async Task CloseCoreAsync(TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
lifetime.Cancel();
|
||||
coordinator.CloseTickets();
|
||||
await drained.Task;
|
||||
coordinator.Dispose();
|
||||
state.Dispose();
|
||||
lifetime.Dispose();
|
||||
lock (admission) closed = true;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
|
||||
async Task<PlayerKVSessionOpenResult> FailCreate(PlayerKVSessionOpenError error,
|
||||
PlayerKVError transport = PlayerKVError.None)
|
||||
{
|
||||
await CloseAsync();
|
||||
return new PlayerKVSessionOpenResult(error, transport);
|
||||
}
|
||||
|
||||
bool IsCapturedOwnerCurrent()
|
||||
{
|
||||
if (lifetime.IsCancellationRequested || !isCurrent(Epoch) || state.AuthenticationInvalid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) &&
|
||||
session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri;
|
||||
}
|
||||
|
||||
static Uri NormalizeApi(Uri api)
|
||||
{
|
||||
if (api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") ||
|
||||
api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0)
|
||||
throw new ArgumentException("Invalid API base URI.");
|
||||
return new Uri(api.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
static PlayerKVSessionOpenError MapOpen(PlayerKVOpenError error)
|
||||
{
|
||||
if (error == PlayerKVOpenError.Busy) return PlayerKVSessionOpenError.Busy;
|
||||
if (error == PlayerKVOpenError.RecoveryRequired) return PlayerKVSessionOpenError.RecoveryRequired;
|
||||
if (error == PlayerKVOpenError.StorageError) return PlayerKVSessionOpenError.StorageError;
|
||||
return PlayerKVSessionOpenError.Validation;
|
||||
}
|
||||
|
||||
PlayerKVEditResult ClosedEdit() => new PlayerKVEditResult(
|
||||
new PlayerKVStateResult(PlayerKVChangeError.Blocked, revision: state.Inspect().Revision));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ = CloseAsync().ContinueWith(task => { var observed = task.Exception; },
|
||||
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e46e2622d9b94d61aeb796d53cea5cdc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,92 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVShutdownStatus { Ready, Pending, Failed, Cancelled, Input }
|
||||
|
||||
/// <summary>Process-lifetime barrier. Faulted and unfinished work cannot be reset by replacing a scene/container.</summary>
|
||||
internal sealed class PlayerKVShutdownRegistry
|
||||
{
|
||||
internal static PlayerKVShutdownRegistry Process { get; } = new PlayerKVShutdownRegistry();
|
||||
readonly object gate = new object();
|
||||
readonly Dictionary<Task, PlayerKVLoginContext> pending = new Dictionary<Task, PlayerKVLoginContext>();
|
||||
TaskCompletionSource<bool> changed = Signal();
|
||||
|
||||
internal void Register(Task task, PlayerKVLoginContext owner = null)
|
||||
{
|
||||
if (task == null) throw new ArgumentNullException(nameof(task));
|
||||
lock (gate)
|
||||
{
|
||||
if (!pending.ContainsKey(task) || owner != null) pending[task] = owner;
|
||||
NotifyChanged();
|
||||
}
|
||||
_ = task.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted) { _ = t.Exception; }
|
||||
lock (gate)
|
||||
{
|
||||
if (t.Status == TaskStatus.RanToCompletion) pending.Remove(t);
|
||||
NotifyChanged();
|
||||
}
|
||||
// Cancelled work does not prove resource cleanup; retain it as a failure barrier.
|
||||
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
internal PlayerKVShutdownStatus Status
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (pending.Keys.Any(t => t.IsFaulted || t.IsCanceled)) return PlayerKVShutdownStatus.Failed;
|
||||
return pending.Keys.Any(t => !t.IsCompleted) ? PlayerKVShutdownStatus.Pending : PlayerKVShutdownStatus.Ready;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Injection is an observation clock only, never a force-close or permission to reopen.
|
||||
internal async Task<PlayerKVShutdownStatus> ObserveAsync(int timeoutMs, CancellationToken token = default,
|
||||
Func<int, CancellationToken, Task> delay = null)
|
||||
{
|
||||
if (timeoutMs < 1000 || timeoutMs > 15000) return PlayerKVShutdownStatus.Input;
|
||||
if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled;
|
||||
using (var clock = CancellationTokenSource.CreateLinkedTokenSource(token))
|
||||
{
|
||||
var deadline = (delay ?? Task.Delay)(timeoutMs, clock.Token);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled;
|
||||
Task notification;
|
||||
lock (gate)
|
||||
{
|
||||
var status = Status;
|
||||
if (status != PlayerKVShutdownStatus.Pending) return status;
|
||||
notification = changed.Task;
|
||||
}
|
||||
if (await Task.WhenAny(notification, deadline).ConfigureAwait(false) == deadline)
|
||||
{
|
||||
if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled;
|
||||
return Status;
|
||||
}
|
||||
// Recheck the live collection; another Root may have registered work during the await.
|
||||
}
|
||||
}
|
||||
finally { clock.Cancel(); }
|
||||
}
|
||||
}
|
||||
|
||||
void NotifyChanged()
|
||||
{
|
||||
var previous = changed; changed = Signal(); previous.TrySetResult(true);
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> Signal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c524f69ad00844b4a1d0456b4d3c43ae
|
||||
@@ -1,707 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVTicketStatus { Pending, Confirmed, Superseded, Blocked, Unknown, Cancelled }
|
||||
|
||||
public sealed class PlayerKVSyncTicket
|
||||
{
|
||||
public string OwnerInstanceId { get; }
|
||||
public long Epoch { get; }
|
||||
public string Key { get; }
|
||||
public long Sequence { get; }
|
||||
internal PlayerKVSyncTicket(string ownerId, long epoch, string key, long sequence)
|
||||
{ OwnerInstanceId = ownerId; Epoch = epoch; Key = key; Sequence = sequence; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVTicketResult
|
||||
{
|
||||
public PlayerKVTicketStatus Status { get; }
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVSyncStatus CacheStatus { get; }
|
||||
internal PlayerKVTicketResult(PlayerKVTicketStatus status, PlayerKVError transportError, PlayerKVSyncStatus cacheStatus)
|
||||
{ Status = status; TransportError = transportError; CacheStatus = cacheStatus; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVEditResult
|
||||
{
|
||||
public bool Accepted => StateResult != null && StateResult.Success;
|
||||
public PlayerKVStateResult StateResult { get; }
|
||||
public PlayerKVSyncTicket Ticket { get; }
|
||||
internal PlayerKVEditResult(PlayerKVStateResult result, PlayerKVSyncTicket ticket = null)
|
||||
{ StateResult = result; Ticket = ticket; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVFlushResult
|
||||
{
|
||||
public int Confirmed { get; }
|
||||
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, int buffered = 0)
|
||||
{
|
||||
Buffered = buffered;
|
||||
Confirmed = confirmed; Superseded = superseded; Blocked = blocked; Unknown = unknown;
|
||||
TransportError = transportError; CacheStatus = cacheStatus;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One fixed owner's serialized Get/Set/Delete channel. No timers or automatic retries.</summary>
|
||||
public sealed class PlayerKVSyncCoordinator : IDisposable
|
||||
{
|
||||
sealed class TicketRecord
|
||||
{
|
||||
internal readonly PlayerKVSyncTicket Ticket;
|
||||
internal readonly TaskCompletionSource<PlayerKVTicketResult> Completion =
|
||||
new TaskCompletionSource<PlayerKVTicketResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal bool Dispatched, ClaimedByFlush;
|
||||
internal TicketRecord(PlayerKVSyncTicket ticket) { Ticket = ticket; }
|
||||
}
|
||||
|
||||
readonly object ticketGate = new object();
|
||||
readonly Dictionary<long, TicketRecord> tickets = new Dictionary<long, TicketRecord>();
|
||||
readonly SemaphoreSlim dispatch = new SemaphoreSlim(1, 1);
|
||||
readonly PlayerKVSyncState state;
|
||||
readonly PlayerKVClient client;
|
||||
readonly System.Net.Http.HttpClient http;
|
||||
readonly string ownerId;
|
||||
readonly long epoch;
|
||||
readonly Func<bool> isOwnerCurrent;
|
||||
readonly CancellationToken ownerToken;
|
||||
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,
|
||||
CancellationToken ownerToken)
|
||||
{
|
||||
this.state = state; this.client = client; this.http = http; this.ownerId = ownerId;
|
||||
this.epoch = epoch; this.isOwnerCurrent = isOwnerCurrent; this.ownerToken = ownerToken;
|
||||
}
|
||||
|
||||
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, result.UpdatedAtUtc, prepareOriginalComparison);
|
||||
return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol);
|
||||
}
|
||||
state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized);
|
||||
return result;
|
||||
}
|
||||
finally { dispatch.Release(); }
|
||||
}
|
||||
|
||||
internal PlayerKVStateResult CompareDatas(long nowTicks)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
internal PlayerKVEditResult Delete(string key)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal long CaptureWatermark(out long[] targetSequences)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<PlayerKVChange> Outstanding(PlayerKVDiagnosticSnapshot snapshot)
|
||||
{
|
||||
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;
|
||||
var sent = 0;
|
||||
while (Current() && sent < maximumBatches)
|
||||
{
|
||||
PlayerKVStateResult begin;
|
||||
PlayerKVBatch batch;
|
||||
lock (ticketGate)
|
||||
{
|
||||
begin = state.BeginBatch(watermark, legacyOnly, out batch);
|
||||
if (begin.Success && batch != null)
|
||||
{
|
||||
MarkDispatched(batch);
|
||||
}
|
||||
}
|
||||
if (!begin.Success)
|
||||
{
|
||||
MarkExplicitBlocked(watermark, legacyOnly, PlayerKVError.None, observed);
|
||||
break;
|
||||
}
|
||||
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)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
RecordBatch(batch, failedStatus, result.Error, observed);
|
||||
break;
|
||||
}
|
||||
if (!Current())
|
||||
{
|
||||
error = PlayerKVError.Cancelled;
|
||||
CloseTickets();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
error = PlayerKVError.Cancelled;
|
||||
CloseTickets();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token)
|
||||
{
|
||||
if (ticket == null || ticket.OwnerInstanceId != ownerId || ticket.Epoch != epoch)
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, state.Status);
|
||||
TicketRecord record;
|
||||
lock (ticketGate)
|
||||
if (!tickets.TryGetValue(ticket.Sequence, out record) || record.Ticket.Key != ticket.Key)
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, state.Status);
|
||||
if (!token.CanBeCanceled) return await record.Completion.Task;
|
||||
if (token.IsCancellationRequested)
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Cancelled, PlayerKVError.Cancelled, state.Status);
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (token.Register(() => cancelled.TrySetResult(true)))
|
||||
{
|
||||
if (await Task.WhenAny(record.Completion.Task, cancelled.Task) == record.Completion.Task)
|
||||
return await record.Completion.Task;
|
||||
}
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Cancelled, PlayerKVError.Cancelled, state.Status);
|
||||
}
|
||||
|
||||
internal void CloseTickets()
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (!record.Completion.Task.IsCompleted)
|
||||
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled,
|
||||
PlayerKVError.Cancelled);
|
||||
}
|
||||
|
||||
void MarkDispatched(PlayerKVBatch batch)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var item in batch.Items)
|
||||
if (tickets.TryGetValue(item.Sequence, out var record)) record.Dispatched = true;
|
||||
}
|
||||
|
||||
void CompleteBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError error)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var item in batch.Items)
|
||||
if (tickets.TryGetValue(item.Sequence, out var record)) Complete(record, status, error);
|
||||
}
|
||||
|
||||
void Complete(TicketRecord record, PlayerKVTicketStatus status, PlayerKVError error) =>
|
||||
record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status));
|
||||
|
||||
private PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error,
|
||||
Dictionary<long, PlayerKVTicketStatus> observed = null)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
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)
|
||||
{
|
||||
unknown.UnionWith(snapshot.UnknownBatch.Items.Select(item => item.Sequence));
|
||||
}
|
||||
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();
|
||||
static PlayerKVResult CancelledResult() => new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
http.Dispose();
|
||||
dispatch.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ff223eaf9574c4894c4acbb6b06a387
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,585 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVSyncStatus { NotLoaded, Ready, ReadFailed, Conflict, UnknownOutcome, Unauthorized, StorageError, Closed }
|
||||
public enum PlayerKVOpenError { None, Validation, Busy, StorageError, RecoveryRequired }
|
||||
public enum PlayerKVChangeError { None, Validation, Blocked, StorageError }
|
||||
|
||||
public sealed class PlayerKVStateResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVChangeError.None;
|
||||
public PlayerKVChangeError Error { get; }
|
||||
public long AttemptSequence { get; }
|
||||
public long Revision { get; }
|
||||
public bool LocalCommitMayHaveSucceeded { get; }
|
||||
internal PlayerKVStateResult(PlayerKVChangeError error, long sequence = 0, long revision = 0, bool uncertain = false)
|
||||
{ Error = error; AttemptSequence = sequence; Revision = revision; LocalCommitMayHaveSucceeded = uncertain; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVOpenResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVOpenError.None;
|
||||
public PlayerKVOpenError Error { get; }
|
||||
public PlayerKVSyncState State { get; }
|
||||
internal PlayerKVOpenResult(PlayerKVOpenError error, PlayerKVSyncState state = null) { Error = error; State = state; }
|
||||
}
|
||||
|
||||
// Existence is separate from value: an empty string is not a deletion.
|
||||
public sealed class PlayerKVValue : IEquatable<PlayerKVValue>
|
||||
{
|
||||
public static readonly PlayerKVValue Missing = new PlayerKVValue(false, null);
|
||||
public bool Exists { get; }
|
||||
public string Value { get; }
|
||||
PlayerKVValue(bool exists, string value) { Exists = exists; Value = value; }
|
||||
public static PlayerKVValue Present(string value) => new PlayerKVValue(true, value ?? throw new ArgumentNullException(nameof(value)));
|
||||
public bool Equals(PlayerKVValue other) => other != null && Exists == other.Exists && Value == other.Value;
|
||||
public override bool Equals(object obj) => Equals(obj as PlayerKVValue);
|
||||
public override int GetHashCode() => Exists ? (Value.GetHashCode() ^ 397) : 0;
|
||||
internal static PlayerKVValue At(IReadOnlyDictionary<string, string> data, string key) =>
|
||||
data.TryGetValue(key, out var value) ? Present(value) : Missing;
|
||||
}
|
||||
|
||||
public sealed class PlayerKVChange
|
||||
{
|
||||
public string Key { get; }
|
||||
public long Sequence { get; }
|
||||
public PlayerKVValue BaseValue { get; }
|
||||
public PlayerKVValue Target { get; }
|
||||
internal PlayerKVChange(string key, long sequence, PlayerKVValue baseline, PlayerKVValue target)
|
||||
{ Key = key; Sequence = sequence; BaseValue = baseline; Target = target; }
|
||||
internal PlayerKVChange WithBase(PlayerKVValue baseline) => new PlayerKVChange(Key, Sequence, baseline, Target);
|
||||
}
|
||||
|
||||
public sealed class PlayerKVBatch
|
||||
{
|
||||
public string Id { get; }
|
||||
public IReadOnlyList<PlayerKVChange> Items { get; }
|
||||
public bool IsDelete => !Items[0].Target.Exists;
|
||||
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
|
||||
{
|
||||
public long Revision { get; }
|
||||
public long NextSequence { get; }
|
||||
public bool HasBaseline { get; }
|
||||
public bool AuthenticationInvalid { get; }
|
||||
public IReadOnlyDictionary<string, string> Baseline { get; }
|
||||
public IReadOnlyDictionary<string, string> LocalOnly { get; }
|
||||
public IReadOnlyDictionary<string, PlayerKVChange> Pending { get; }
|
||||
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;
|
||||
AuthenticationInvalid = source.AuthenticationInvalid;
|
||||
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));
|
||||
}
|
||||
|
||||
internal sealed class KVIdentity
|
||||
{
|
||||
internal readonly string Api, PlayerId, DirectoryPath;
|
||||
internal KVIdentity(string root, Uri api, string playerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root) || api == null || !api.IsAbsoluteUri ||
|
||||
(api.Scheme != "http" && api.Scheme != "https") || api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0 ||
|
||||
!long.TryParse(playerId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0)
|
||||
throw new ArgumentException("Invalid KV cache identity.");
|
||||
Api = api.AbsoluteUri.TrimEnd('/') + "/";
|
||||
PlayerId = id.ToString(CultureInfo.InvariantCulture);
|
||||
using (var hash = SHA256.Create())
|
||||
{
|
||||
var bytes = hash.ComputeHash(Encoding.UTF8.GetBytes(Api));
|
||||
var fingerprint = string.Concat(bytes.Select(b => b.ToString("x2", CultureInfo.InvariantCulture)));
|
||||
DirectoryPath = Path.Combine(Path.GetFullPath(root), "player-kv", "v1", fingerprint, PlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class KVSnapshot
|
||||
{
|
||||
internal long Revision, NextSequence = 1;
|
||||
internal bool HasBaseline, AuthenticationInvalid;
|
||||
internal Dictionary<string, string> Baseline = new Dictionary<string, string>(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;
|
||||
|
||||
// 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 conflicts = new JObject();
|
||||
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),
|
||||
["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);
|
||||
}
|
||||
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)
|
||||
{
|
||||
JObject root;
|
||||
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();
|
||||
}
|
||||
}
|
||||
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)),
|
||||
Active = Batch(root, "activeBatch", version == 3), Unknown = Batch(root, "unknownBatch", version == 3)
|
||||
};
|
||||
foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties())
|
||||
{
|
||||
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 || 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)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
var sequences = new Dictionary<long, PlayerKVChange>();
|
||||
void Add(PlayerKVChange item, bool sourceReference = false)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var property in ((JObject)value).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name) || property.Value.Type != JTokenType.String) throw Invalid();
|
||||
result.Add(property.Name, (string)property.Value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static PlayerKVChange Change(JToken item)
|
||||
{
|
||||
var key = String(item, "key");
|
||||
if (!ValidKey(key)) throw Invalid();
|
||||
return new PlayerKVChange(key, Integer(item, "sequence"), Value(Field(item, "base", JTokenType.Object)), Value(Field(item, "target", JTokenType.Object)));
|
||||
}
|
||||
static PlayerKVValue Value(JToken item)
|
||||
{
|
||||
var exists = Boolean(item, "exists");
|
||||
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, bool withKind = false)
|
||||
{
|
||||
var node = parent[name];
|
||||
if (node == null) throw Invalid();
|
||||
if (node.Type == JTokenType.Null) return null;
|
||||
var id = String(node, "id");
|
||||
if (!Guid.TryParseExact(id, "N", out _)) throw Invalid();
|
||||
var items = Field(node, "items", JTokenType.Array).Select(Change).ToArray();
|
||||
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, withKind && Boolean(node, "ordinary"));
|
||||
}
|
||||
static JToken Field(JToken parent, string name, JTokenType type)
|
||||
{
|
||||
if (!(parent is JObject obj) || obj[name] == null || obj[name].Type != type) throw Invalid();
|
||||
return obj[name];
|
||||
}
|
||||
static string String(JToken parent, string name) => (string)Field(parent, name, JTokenType.String);
|
||||
static long Integer(JToken parent, string name) => (long)Field(parent, name, JTokenType.Integer);
|
||||
static bool Boolean(JToken parent, string name) => (bool)Field(parent, name, JTokenType.Boolean);
|
||||
internal static JsonException Invalid() => new JsonException("Invalid KV cache snapshot.");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c949512a03dc431b94569c20f06102af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,818 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Synchronous, fixed-identity cache state. No HTTP, gameplay initialization or timers.
|
||||
/// All transitions and disk writes share a gate. The phase-3B owner must drain its
|
||||
/// admitted network operations before disposing this state and its filesystem lease.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVSyncState : IDisposable
|
||||
{
|
||||
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; }
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
internal static PlayerKVOpenResult Open(string root, Uri api, string playerId, Action<KVCacheWriteStage> fault)
|
||||
{
|
||||
KVIdentity identity;
|
||||
try { identity = new KVIdentity(root, api, playerId); }
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException)
|
||||
{ return new PlayerKVOpenResult(PlayerKVOpenError.Validation); }
|
||||
var error = PlayerKVCacheStore.Open(identity, fault, out var store, out var snapshot);
|
||||
return error == PlayerKVOpenError.None ? new PlayerKVOpenResult(error, new PlayerKVSyncState(identity, store, snapshot)) : new PlayerKVOpenResult(error);
|
||||
}
|
||||
|
||||
public PlayerKVDiagnosticSnapshot Inspect() { lock (gate) return new PlayerKVDiagnosticSnapshot(snapshot); }
|
||||
|
||||
public IReadOnlyDictionary<string, string> ReadLocal()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready.");
|
||||
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 || 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 || (recoveredOrdinaryUnknown && !prepareOriginalComparison))
|
||||
{
|
||||
foreach (var item in copy.Unsent())
|
||||
{
|
||||
copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key);
|
||||
}
|
||||
return Commit(copy, PlayerKVSyncStatus.UnknownOutcome);
|
||||
}
|
||||
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) && !observed.Equals(item.BaseValue))
|
||||
{
|
||||
copy.Conflicts[item.Key] = observed;
|
||||
}
|
||||
}
|
||||
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;
|
||||
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)
|
||||
{
|
||||
if (Terminal || snapshot.Active != null) return Blocked();
|
||||
if (unauthorized)
|
||||
{
|
||||
var copy = snapshot.Clone(); copy.AuthenticationInvalid = true;
|
||||
return Commit(copy, copy.Unknown == null ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.UnknownOutcome);
|
||||
}
|
||||
status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.ReadFailed;
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerKVStateResult Update(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
return Update(key, value, remoteStore, null);
|
||||
}
|
||||
|
||||
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) || 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);
|
||||
}
|
||||
}
|
||||
|
||||
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.AuthenticationInvalid)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (snapshot.OrdinaryBuffers[0].Count == 0)
|
||||
{
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
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 = 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;
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerKVStateResult ConfirmBatch(string batchId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
var ordinary = copy.Active.IsOrdinary;
|
||||
copy.AuthenticationInvalid |= unauthorized;
|
||||
if (mayHaveCommitted)
|
||||
{
|
||||
if (ordinary)
|
||||
{
|
||||
copy.OrdinaryUnknownBatches.Add(copy.Active);
|
||||
}
|
||||
else
|
||||
{
|
||||
copy.Unknown = copy.Active;
|
||||
}
|
||||
}
|
||||
copy.Active = null;
|
||||
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)
|
||||
{
|
||||
if (snapshot.Revision == long.MaxValue)
|
||||
{ status = PlayerKVSyncStatus.StorageError; return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, snapshot.Revision); }
|
||||
copy.Revision = snapshot.Revision + 1;
|
||||
if (!store.Save(copy, out var uncertain))
|
||||
{
|
||||
status = PlayerKVSyncStatus.StorageError;
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, copy.Revision, uncertain);
|
||||
}
|
||||
snapshot = copy; status = nextStatus;
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.None, sequence, copy.Revision);
|
||||
}
|
||||
static void Apply(Dictionary<string, string> data, string key, PlayerKVValue value)
|
||||
{ if (value.Exists) data[key] = value.Value; else data.Remove(key); }
|
||||
PlayerKVStateResult Blocked() => new PlayerKVStateResult(PlayerKVChangeError.Blocked, revision: snapshot.Revision);
|
||||
PlayerKVStateResult Invalid() => new PlayerKVStateResult(PlayerKVChangeError.Validation, revision: snapshot.Revision);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status == PlayerKVSyncStatus.Closed) return;
|
||||
status = PlayerKVSyncStatus.Closed;
|
||||
ClearComparison();
|
||||
store.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0aabf3ee88684f80ba8770cbf96a9be8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,180 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public sealed class KVPlayerSaveStore : IPlayerSaveStore, IDisposable
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (bound || disposed || original == null || loginLifetime.IsCancellationRequested)
|
||||
throw new InvalidOperationException("Player save store cannot be rebound.");
|
||||
// This also checks the load guard before permitting business use.
|
||||
if (!KVMgr.ReadKV(original, "LastLoginTimeUtc").Success)
|
||||
throw new InvalidOperationException("Player save handle is not ready.");
|
||||
handle = original;
|
||||
lifetime = loginLifetime;
|
||||
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");
|
||||
var result = KVMgr.ReadKV(handle, key);
|
||||
return result.Success ? new PlayerSaveReadResult(result.Found.Value, result.Value) :
|
||||
new PlayerSaveReadResult(result.Error.ToString());
|
||||
}
|
||||
|
||||
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
lock (receiptsGate)
|
||||
{
|
||||
if (!IsCurrent)
|
||||
{
|
||||
var rejected = new PlayerSaveWriteResult("StaleOrNotReady");
|
||||
return rejected;
|
||||
}
|
||||
var result = KVMgr.WriteKV(handle, key, value, remoteStore);
|
||||
var write = result.Accepted ? new PlayerSaveWriteResult(new PlayerSaveReceipt(this, result.Ticket)) :
|
||||
new PlayerSaveWriteResult(result.StateResult.Error.ToString());
|
||||
return write;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SyncDataImmidiate(string key, string value)
|
||||
{
|
||||
PlayerKVGameSessionHandle original;
|
||||
CancellationToken originalLifetime;
|
||||
Task<bool> save;
|
||||
lock (receiptsGate)
|
||||
{
|
||||
if (!IsCurrent)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
PlayerKVGameSessionHandle original;
|
||||
CancellationToken originalLifetime;
|
||||
Task<bool> save;
|
||||
lock (receiptsGate)
|
||||
{
|
||||
if (!IsCurrent || values == null || values.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
internal Task<PlayerKVFlushResult> SyncDataAsync()
|
||||
{
|
||||
lock (receiptsGate)
|
||||
{
|
||||
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 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) { }
|
||||
internal PlayFabPlayerSaveStore(PlayFabMgr manager, CancellationToken lifetime)
|
||||
{ this.manager = manager; this.lifetime = lifetime; }
|
||||
public bool IsCurrent => manager != null && !lifetime.IsCancellationRequested;
|
||||
|
||||
public PlayerSaveReadResult Read(string key)
|
||||
{
|
||||
if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady");
|
||||
try
|
||||
{
|
||||
var value = manager.GetLocalData(key);
|
||||
return new PlayerSaveReadResult(value != null, value);
|
||||
}
|
||||
catch (Exception ex) { return new PlayerSaveReadResult(ex.GetType().Name); }
|
||||
}
|
||||
|
||||
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
if (!IsCurrent) return new PlayerSaveWriteResult("StaleOrNotReady");
|
||||
try
|
||||
{
|
||||
manager.UpdateUserDataValue(key, value, remoteStore);
|
||||
return new PlayerSaveWriteResult(new PlayerSaveReceipt(this, remoteStore ? (object)true : null));
|
||||
}
|
||||
catch (Exception ex) { return new PlayerSaveWriteResult(ex.GetType().Name); }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
using Core.KV;
|
||||
using game.KV;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using Game;
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
namespace game
|
||||
{
|
||||
@@ -29,9 +30,8 @@ namespace game
|
||||
int _unitOfWeight = 1;
|
||||
bool? _showNotice;
|
||||
|
||||
readonly bool requiresFormalKV;
|
||||
PlayerKVGameSessionHandle promptOwner;
|
||||
internal PlayerKVEditResult LastPromptEdit { get; private set; }
|
||||
private readonly bool requiresFormalKV;
|
||||
private KvSessionOwner promptOwner;
|
||||
internal bool HasKVPromptOwner => promptOwner != null;
|
||||
|
||||
// Preserve only our key/owner/error metadata across void UI boundaries, never platform payloads.
|
||||
@@ -46,41 +46,40 @@ namespace game
|
||||
|
||||
public DefaultSettingService() : this(false) { }
|
||||
internal DefaultSettingService(bool formalKV) { requiresFormalKV = formalKV; }
|
||||
internal DefaultSettingService(PlayerKVGameSessionHandle owner) : this(true)
|
||||
{
|
||||
promptOwner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
RequirePromptOwner();
|
||||
}
|
||||
|
||||
// The frozen startup selection is supplied before login; this instance can never change owners.
|
||||
internal void PreflightFormalPromptOwner(PlayerKVGameSessionHandle owner)
|
||||
internal void PreflightFormalPromptOwner(KvSessionOwner owner)
|
||||
{
|
||||
if (!requiresFormalKV || owner == null || (promptOwner != null && !ReferenceEquals(promptOwner, owner)))
|
||||
{
|
||||
throw PromptFailure("ShowNotice: formal owner mismatch.");
|
||||
var read = KVMgr.ReadKV(owner, "ShowNotice");
|
||||
if (!read.Success) throw PromptFailure($"ShowNotice owner={owner.OwnerInstanceId}: {read.Error}");
|
||||
}
|
||||
var read = KvSaveStore.ReadKV(owner, "ShowNotice");
|
||||
if (!read.Success)
|
||||
{
|
||||
throw PromptFailure($"ShowNotice owner={owner.OwnerInstanceId}: {read.Error}");
|
||||
}
|
||||
}
|
||||
internal void BindFormalPromptOwner(PlayerKVGameSessionHandle owner)
|
||||
internal void BindFormalPromptOwner(KvSessionOwner owner)
|
||||
{
|
||||
PreflightFormalPromptOwner(owner);
|
||||
if (promptOwner == null) promptOwner = owner;
|
||||
if (promptOwner == null)
|
||||
{
|
||||
promptOwner = owner;
|
||||
}
|
||||
RequirePromptOwner();
|
||||
}
|
||||
internal bool HasFormalPromptOwner(PlayerKVGameSessionHandle owner) =>
|
||||
requiresFormalKV && ReferenceEquals(promptOwner, owner);
|
||||
|
||||
internal void ReleaseFailedPromptOwner(PlayerKVGameSessionHandle owner)
|
||||
internal void ReleaseFailedPromptOwner(KvSessionOwner owner)
|
||||
{
|
||||
if (promptOwner == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!ReferenceEquals(promptOwner, owner) || owner.Status != PlayerKVSyncStatus.Closed)
|
||||
if (!ReferenceEquals(promptOwner, owner) || owner.Status != KVSyncStatus.Closed)
|
||||
{
|
||||
throw PromptFailure("ShowNotice: failed owner has not closed.");
|
||||
}
|
||||
promptOwner = null;
|
||||
LastPromptEdit = null;
|
||||
_showNotice = null;
|
||||
}
|
||||
internal void RequirePromptOwner()
|
||||
@@ -89,9 +88,9 @@ namespace game
|
||||
else if (requiresFormalKV) throw PromptFailure("ShowNotice: formal owner not handed off.");
|
||||
}
|
||||
|
||||
private PlayerKVKeyReadResult ReadPrompt()
|
||||
private PlayerSaveReadResult ReadPrompt()
|
||||
{
|
||||
var read = KVMgr.ReadKV(promptOwner, "ShowNotice");
|
||||
var read = KvSaveStore.ReadKV(promptOwner, "ShowNotice");
|
||||
if (!read.Success)
|
||||
{
|
||||
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {read.Error}");
|
||||
@@ -202,10 +201,10 @@ namespace game
|
||||
if (value != _showNotice)
|
||||
{
|
||||
_showNotice = value;
|
||||
LastPromptEdit = KVMgr.WriteKV(promptOwner, "ShowNotice", value ? "1" : "0");
|
||||
if (!LastPromptEdit.Accepted)
|
||||
var promptEdit = KvSaveStore.WriteKV(promptOwner, "ShowNotice", value ? "1" : "0");
|
||||
if (!promptEdit.Success)
|
||||
{
|
||||
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
|
||||
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {promptEdit.Error}; uncertain={promptEdit.LocalCommitMayHaveSucceeded}");
|
||||
}
|
||||
}
|
||||
RequirePromptOwner();
|
||||
|
||||
@@ -21,14 +21,14 @@ public class SurveyService
|
||||
const string SurveyData = "SurveyData";
|
||||
const string SurveyReadSidData = "SurveyReadSidData";
|
||||
|
||||
readonly game.PlayerDataStorage.IPlayerSaveStore saveStore;
|
||||
private readonly Core.KV.IPlayerSaveStore saveStore;
|
||||
IUserService userService;
|
||||
cfg.Tables tables;
|
||||
List<string> receivedReward = new List<string>();
|
||||
List<string> readSid = new List<string>();
|
||||
string access_token;
|
||||
public SurveyData tableData;
|
||||
public SurveyService(IUserService userService, cfg.Tables tables, game.PlayerDataStorage.IPlayerSaveStore saveStore)
|
||||
public SurveyService(IUserService userService, cfg.Tables tables, Core.KV.IPlayerSaveStore saveStore)
|
||||
{
|
||||
this.userService = userService;
|
||||
this.saveStore = saveStore;
|
||||
@@ -55,7 +55,7 @@ public class SurveyService
|
||||
PlayerData playerData = GContext.container.Resolve<PlayerData>();
|
||||
|
||||
List<SurveyData> surveyDataTable = tables.TbSurveyData.DataList;
|
||||
ulong userId = Convert.ToUInt64(userService.UserId, saveStore is game.PlayerDataStorage.KVPlayerSaveStore ? 10 : 16);
|
||||
ulong userId = Convert.ToUInt64(userService.UserId, saveStore is game.KV.KvSaveStore ? 10 : 16);
|
||||
tableData = null;
|
||||
if (surveyDataTable.Count > 0)
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace game
|
||||
{
|
||||
titleNewsEvent.titleNewsItem = News;
|
||||
bool isRod = false;
|
||||
GetTitleNewsResult result = GContext.container.Resolve<IUserService>() is KVUserService
|
||||
GetTitleNewsResult result = GContext.container.Resolve<IUserService>() is KvUserService
|
||||
? await GetKVTitleNewsAsync()
|
||||
: await PlayFabClientAsyncAPI.GetTitleNewsAsync(new PlayFab.ClientModels.GetTitleNewsRequest());
|
||||
if (result != null && result.News != null && result.News.Count > 0)
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using cfg;
|
||||
using GameCore;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal static partial class PlayerKVGameplayLoop
|
||||
{
|
||||
internal sealed class FishingRewardPlan
|
||||
{
|
||||
internal FishingData Data;
|
||||
internal int Fish, Item, Area, Rod, Map, Mag;
|
||||
internal string Activities;
|
||||
internal string MapKey => "Map_5_"+Area;
|
||||
}
|
||||
internal sealed class FishingRewardAction
|
||||
{
|
||||
internal readonly FishingAction Consumption;
|
||||
internal readonly object Controller;
|
||||
internal readonly FishingRewardPlan Plan;
|
||||
internal readonly List<WriteRecord> Records=new List<WriteRecord>();
|
||||
internal readonly HashSet<string> Segments=new HashSet<string>(StringComparer.Ordinal);
|
||||
internal object Panel;
|
||||
internal WriteBatch Segment;
|
||||
internal string SegmentName;
|
||||
internal bool Ending,Applying,Sealed,Confirmed,Presented,Cancelled;
|
||||
internal int GuideNotifications,BeforeFishCount;
|
||||
internal FishingRewardAction(FishingAction consumption,object controller,FishingRewardPlan plan)
|
||||
{ Consumption=consumption; Controller=controller; Plan=plan; }
|
||||
}
|
||||
static FishingRewardPlan rewardPlan;
|
||||
static FishingRewardAction rewardAction;
|
||||
static readonly ConditionType[] rewardConditions={ConditionType.ConsumeEnergy,ConditionType.PerfectStrikeCount,
|
||||
ConditionType.GetFishCount,ConditionType.CashFromFishing,ConditionType.MaxCashFromFishing,
|
||||
ConditionType.GetFishWeight,ConditionType.GetFishID,ConditionType.GetBoosFish,ConditionType.GetBiggerThanHugeFishCount};
|
||||
static readonly HashSet<string> rewardTaskKeys=new HashSet<string>(StringComparer.Ordinal)
|
||||
{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"};
|
||||
|
||||
static string ActivityStamp(Tables tables)
|
||||
{
|
||||
var manager=GContext.container.Resolve<Activity.ActivityManager>();
|
||||
var scopes=GContext.container.Resolve<Activity.ActivityScopeManager>();
|
||||
return string.Join(";",tables.TbFishingEvent.DataList.GroupBy(e=>(e.Type,e.SubType)).Select(g=>g.First())
|
||||
.Select(e=>e.Type+":"+e.SubType+":"+manager.GetSentry(e)?.CurrentEventConfig?.ID+":"+scopes.HasActiveScope((e.Type,e.SubType))));
|
||||
}
|
||||
static readonly HashSet<string> specialRewardGuides=new HashSet<string>(StringComparer.Ordinal)
|
||||
{"FishSkill01","Pollution01","FishSkill02","FishSkill03","FishSkill04","Egyptian01","EasterFish01"};
|
||||
internal static void CheckRewardGuide(FishingData data=null)
|
||||
{
|
||||
var guide=GContext.container.Resolve<GuideDataCenter>();
|
||||
if(guide.curDefine!=null || guide.curGroupDefine!=null || guide.guidanceGroupDefines==null ||
|
||||
guide.guidanceGroupDefines.Any(g=>g==null || g.guidanceDefines==null || g.guidanceDefines.Any(n=>n==null)))
|
||||
throw Stop("Reward requires inactive and complete guide definitions.");
|
||||
var groups=guide.guidanceGroupDefines.Where(g=>g.guidanceDefines.Any(n=>n.panelName=="FishingPanel" ||
|
||||
n.panelName=="FishingPanel_Advanced" || n.panelName=="FishingRewardPanel"))
|
||||
.OrderBy(g=>g.groupName.ToString(),StringComparer.Ordinal).ToArray();
|
||||
var asset=Path.Combine(Application.dataPath,"ABPackage/pkgPrepare/Export/GuidanceConfig.asset");
|
||||
var baseline=File.ReadAllBytes(Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVFishingRewardTests/guide-baseline-projection.jsonl")));
|
||||
if(PlayerKVLoopPermit.Hash(File.ReadAllBytes(asset))!="5926124E5633166CC1B91569B8A71AB2C388AA3446A736B3E7E42B3EBD9F3D3A" ||
|
||||
PlayerKVLoopPermit.Hash(baseline)!="A3D056313FE0F40D395FE92F97BEDE31BA47D8D79A3C4C28781395A43118E89C" || groups.Length!=11 ||
|
||||
string.Join("\n",groups.Select(g=>JsonUtility.ToJson(g)))!=System.Text.Encoding.UTF8.GetString(baseline))
|
||||
throw Stop("Reward guide configuration differs from the reviewed full-node projection.");
|
||||
foreach(var g in groups)
|
||||
if(!guide.GetIsFinish(g.groupName.ToString()) && (!specialRewardGuides.Contains(g.groupName.ToString()) ||
|
||||
g.TriggerConditionsType!=GuideTriggerConditions.None || g.guidanceDefines.Length==0 || g.guidanceDefines[0].panelName!="FishingPanel_Advanced"))
|
||||
throw Stop("Reward requires completed ordinary fishing guides.");
|
||||
data=data??rewardPlan?.Data;
|
||||
if(data?.curFishData?.FishSKillList==null || data.curFishData.FishEscape==null || data.curFishData.FishSKillList.Any(x=>x==null) ||
|
||||
data.fishSpSkillData!=null || data.superDashSpSkillData!=null || data.dirtyStuckSpSkillData!=null)
|
||||
throw Stop("Reward requires an ordinary fish without active special skills.");
|
||||
var tables=GContext.container.Resolve<Tables>();
|
||||
var queried=data.curFishData.FishSKillList.Concat(new[]{data.curFishData.FishEscape}).Select(x=>x.SpSkillID).Distinct().OrderBy(x=>x).ToArray();
|
||||
bool invalid=false;
|
||||
foreach(var id in queried) { var resolved=tables.TbFishSpSkill.GetOrDefault(id); if(id>0 || resolved!=null)invalid=true; }
|
||||
Audit("fishing-reward-special-skill-check",new{queriedIds=queried,invalid});
|
||||
if(invalid) throw Stop("Reward selected fish has a special skill; no consumption is allowed.");
|
||||
}
|
||||
static string EventSignature(FishingEvent e) => JsonConvert.SerializeObject(new{e.ID,e.Type,e.SubType,e.RedirectID,
|
||||
e.IsABTest,e.Channel,e.ConditionRelation,e.PopupPriority,e.PopupType,
|
||||
conditions=e.ConditionList.Select(c=>new{c.Condition,c.Param}),time=e.TimeDefinition.ToString()});
|
||||
static void CheckRewardConfig(Tables tables)
|
||||
{
|
||||
var path=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData/tbfishingevent.json"));
|
||||
var bytes=File.ReadAllBytes(path);
|
||||
if(PlayerKVLoopPermit.Hash(bytes)!="C8A4A9794E9390C94B9C8F1927B3E718B3EB9F784F0889508E1E576DD4B77A2D")
|
||||
throw Stop("Reward event configuration differs from the reviewed baseline.");
|
||||
var baseline=new TbFishingEvent(SimpleJSON.JSON.Parse(System.Text.Encoding.UTF8.GetString(bytes)));
|
||||
var relevant=tables.TbFishingEvent.DataList.Where(e=>e.ConditionList.Any(c=>rewardConditions.Contains(c.Condition))).ToArray();
|
||||
if(relevant.Length!=19 || tables.TbTriggerPackManager.DataList.Any(t=>rewardConditions.Contains(t.Condition)))
|
||||
throw Stop("Reward activates an unreviewed event or trigger pack.");
|
||||
foreach(var e in relevant)
|
||||
{
|
||||
var old=baseline.GetOrDefault(e.ID);
|
||||
if(old==null || EventSignature(old)!=EventSignature(e)) throw Stop("Reward event fields changed.");
|
||||
if(e.ID==10100000 || e.ID==10200000 || e.ID==11300000) continue;
|
||||
if(e.ID<30200350 || e.ID>30200365 || !(e.TimeDefinition is LimitedTime time) ||
|
||||
!DateTime.TryParse(time.EndTime,CultureInfo.InvariantCulture,DateTimeStyles.None,out var end) ||
|
||||
ZZTimeHelper.UtcNow() < end.AddSeconds(120)) throw Stop("Reward event must be outside its reviewed activity window.");
|
||||
}
|
||||
}
|
||||
static void CheckRewardEnvironment(FishingData data,bool checkConfig)
|
||||
{
|
||||
var player=GContext.container.Resolve<PlayerData>();
|
||||
var fish=GContext.container.Resolve<PlayerFishData>();
|
||||
var events=GContext.container.Resolve<FishingEventData>();
|
||||
var challenge=GContext.container.Resolve<FishingChallengeManager>();
|
||||
if(data==null || data.curFishData==null || data.fishItem==null || !data.IsGeneralFish() || data.IsFree ||
|
||||
data.IsRetry || data.IsDuel || data.fixedFishList!=null || data.IsPackItemFish() || data.IsEventGameFish())
|
||||
throw Stop("Reward supports one ordinary paid fish only.");
|
||||
var tables=GContext.container.Resolve<Tables>();
|
||||
if(tables.TbEventSlapSpinach.TryGetByItemID(data.fishItem.ID,out _)) throw Stop("Slap reward is outside this run.");
|
||||
var cruise=fish.GetFishSwimToMap(data.MapId);
|
||||
if((cruise!=-1 && cruise!=0) || events.collectingTargetInit!=null ||
|
||||
GContext.container.Resolve<CollectingData>().GetAddCollecting()!=0 ||
|
||||
GContext.container.Resolve<PiggyBankPackData>().IsPackActivated || GContext.container.Resolve<BargainPackData>().IsPackActivated)
|
||||
throw Stop("Additional collection, cruise or pack rewards require separate review.");
|
||||
if(challenge.fishingChallengeData!=null && challenge.ChallengeState()!=0 && challenge.ChallengeState()!=3 && challenge.IsTimeAllow())
|
||||
throw Stop("Active challenge rewards require separate review.");
|
||||
if(player._fishingFrequency<3 || !player.IsOpenMagnification || !fish.IsOpenPiercing)
|
||||
throw Stop("Reward requires already unlocked basic fishing.");
|
||||
CheckRewardGuide(data);
|
||||
if(checkConfig) CheckRewardConfig(tables);
|
||||
}
|
||||
internal static void PrepareFishingReward(FishingAction action,FishingData data)
|
||||
{
|
||||
if(Permit?.AllowsReward!=true) return;
|
||||
if(rewardPlan!=null || !FishingCurrent(action)) throw Stop("Reward preflight already used or stale.");
|
||||
CheckRewardEnvironment(data,true);
|
||||
var p=GContext.container.Resolve<PlayerData>();
|
||||
var plan=new FishingRewardPlan{Data=data,Fish=data.curFishData.ID,Item=data.fishItem.ID,Area=data.curFishData.AreaID,
|
||||
Rod=data.RodId,Map=p.lastMapId,Mag=p.magnification,Activities=ActivityStamp(GContext.container.Resolve<Tables>())};
|
||||
if(!Permit.AllowedKeys.Contains(plan.MapKey) || Read(plan.MapKey)==null) throw Stop("Reward requires an existing backed-up fish map key.");
|
||||
rewardPlan=plan;
|
||||
Audit("fishing-reward-preflight",new{plan.Fish,plan.Item,plan.Area,plan.Rod,plan.Map,plan.Mag});
|
||||
}
|
||||
static void CheckRewardContext()
|
||||
{
|
||||
var p=rewardPlan; var player=GContext.container.Resolve<PlayerData>();
|
||||
if(p==null || p.Data.curFishData==null || p.Data.fishItem==null || p.Data.curFishData.ID!=p.Fish || p.Data.fishItem.ID!=p.Item ||
|
||||
p.Data.curFishData.AreaID!=p.Area || p.Data.RodId!=p.Rod || player.lastMapId!=p.Map || player.magnification!=p.Mag)
|
||||
throw Stop("Reward fishing context changed.");
|
||||
CheckRewardEnvironment(p.Data,false);
|
||||
if(p.Activities!=ActivityStamp(GContext.container.Resolve<Tables>())) throw Stop("Activity changed during fishing reward.");
|
||||
}
|
||||
internal static bool BeginRewardDrawing(object controller)
|
||||
{
|
||||
if(Permit?.AllowsReward!=true || !FishingCurrent(fishingAction) || !fishingAction.Confirmed || !fishingAction.Continued)
|
||||
throw Stop("A confirmed v3 fishing action is required before drawing.");
|
||||
if(rewardAction!=null) { Audit("duplicate-suppressed",new{point="draw"}); return false; }
|
||||
CheckRewardContext();
|
||||
rewardAction=new FishingRewardAction(fishingAction,controller,rewardPlan);
|
||||
return true;
|
||||
}
|
||||
internal static bool BeginRewardEnding(object controller,bool success)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(a==null || !ReferenceEquals(controller,a.Controller) || !RewardCurrent(a)) throw Stop("Reward ending is stale.");
|
||||
if(a.Ending) { Audit("duplicate-suppressed",new{point="fish-end"}); return false; }
|
||||
a.Ending=true;
|
||||
if(!success) throw Stop("Fishing failed; reward run stops without retry or refund.");
|
||||
CheckRewardContext(); return true;
|
||||
}
|
||||
internal static FishingRewardAction BindRewardPanel(object panel)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(!RewardCurrent(a) || !a.Ending) throw Stop("Reward panel has no completed catch.");
|
||||
if(a.Applying) { Audit("duplicate-suppressed",new{point="reward-panel"}); return null; }
|
||||
CheckRewardContext();
|
||||
a.Applying=true; a.Panel=panel;
|
||||
var fish=GContext.container.Resolve<PlayerFishData>();
|
||||
var map=JsonConvert.DeserializeObject<Dictionary<int,string>>(Read(a.Plan.MapKey));
|
||||
a.BeforeFishCount=map.TryGetValue(a.Plan.Fish,out var old)?FishWeightData.Deserialize(old).TotalNum:0;
|
||||
return a;
|
||||
}
|
||||
internal static bool RewardCurrent(FishingRewardAction a) => a!=null && ReferenceEquals(a,rewardAction) &&
|
||||
!a.Cancelled && FishingCurrent(a.Consumption);
|
||||
sealed class RewardSegment : IDisposable
|
||||
{
|
||||
readonly FishingRewardAction action;
|
||||
internal RewardSegment(FishingRewardAction a) { action=a; }
|
||||
public void Dispose()
|
||||
{
|
||||
if(action.Segment==null) return;
|
||||
action.Segment.Dispose(); action.Records.AddRange(action.Segment.Records);
|
||||
Audit("fishing-reward-segment",new{segment=action.SegmentName,writes=action.Segment.Records.Count});
|
||||
action.Segment=null;action.SegmentName=null;
|
||||
}
|
||||
}
|
||||
internal static IDisposable BeginRewardSegment(string name)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(!RewardCurrent(a) || a.Sealed || collecting!=null || a.Segment!=null ||
|
||||
(name!="strike" && name!="ending" && name!="reward") || !a.Segments.Add(name)) throw Stop("Reward segment is not admissible.");
|
||||
if((name=="ending" && !a.Ending) || (name=="reward" && !a.Applying)) throw Stop("Reward segment order is invalid.");
|
||||
a.SegmentName=name; a.Segment=collecting=new WriteBatch(a.Consumption.Owner);
|
||||
return new RewardSegment(a);
|
||||
}
|
||||
internal static void CheckRewardWrite(string key,string value)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(Permit?.AllowsReward!=true || a==null || a.Cancelled || a.Confirmed) return;
|
||||
if(!RewardCurrent(a)) throw Stop("Reward write belongs to a stale action.");
|
||||
if(a.Segment==null)
|
||||
{
|
||||
Audit("fishing-reward-outside-segment",new{key});
|
||||
if(!string.Equals(Read(key),value,StringComparison.Ordinal)) throw Stop("Value changed outside a fishing reward segment: "+key);
|
||||
}
|
||||
else if(key!="NewPlayerDataDic" && key!=a.Plan.MapKey && !rewardTaskKeys.Contains(key))
|
||||
throw Stop("Unexpected fishing reward key: "+key);
|
||||
}
|
||||
internal static void RecordRewardGuideNotification()
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(!RewardCurrent(a) || a.SegmentName!="reward" || ++a.GuideNotifications!=1) throw Stop("Duplicate reward guide notification.");
|
||||
CheckRewardGuide(); Audit("fishing-reward-guide",new{notifications=a.GuideNotifications,activated=0});
|
||||
}
|
||||
internal static async Task ConfirmRewardAsync(FishingRewardAction a,PlayerKVEditResult primary)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!RewardCurrent(a) || a.Segment!=null || collecting!=null || a.Sealed || a.Segments.Count!=3 || a.GuideNotifications!=1)
|
||||
throw Stop("Reward records must be complete and sealed once.");
|
||||
a.Sealed=true;
|
||||
var last=a.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal);
|
||||
if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) ||
|
||||
!last.ContainsKey(a.Plan.MapKey) || last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null))
|
||||
throw Stop("Reward final writes require the original map/main and remote tickets.");
|
||||
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
var owner=a.Consumption.Owner;
|
||||
foreach(var r in a.Records)
|
||||
{
|
||||
var t=r.Edit.Ticket;if(t==null)continue;
|
||||
var final=last[r.Key].Edit.Ticket;
|
||||
if(t.OwnerInstanceId!=owner.OwnerInstanceId || t.Epoch!=owner.Epoch || t.Key!=r.Key ||
|
||||
final.OwnerInstanceId!=t.OwnerInstanceId || final.Epoch!=t.Epoch) throw Stop("Reward ticket identity mismatch.");
|
||||
var result=await KVMgr.WaitForKVTicketAsync(owner,t,timeout.Token);
|
||||
if(!RewardCurrent(a)) throw Stop("Reward action changed while waiting.");
|
||||
bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(t,final) && final.Sequence>t.Sequence;
|
||||
if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Reward ticket: "+result.Status);
|
||||
}
|
||||
}
|
||||
a.Confirmed=true;
|
||||
Audit("fishing-reward-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash})});
|
||||
}
|
||||
catch(Exception e) { Fault(e.Message);throw; }
|
||||
}
|
||||
internal static void PresentReward(FishingRewardAction a,Action show)
|
||||
{
|
||||
if(!RewardCurrent(a) || !a.Confirmed) throw Stop("Reward cannot be presented before confirmation.");
|
||||
if(a.Presented)return;a.Presented=true;show();Audit("fishing-reward-presented",new{a.Plan.Fish});
|
||||
}
|
||||
internal static void CancelReward(object actor,string reason)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(a==null || a.Cancelled || (!ReferenceEquals(actor,a.Controller) && !ReferenceEquals(actor,a.Panel) && !ReferenceEquals(actor,a.Consumption.Actor)))return;
|
||||
if(a.Ending && !a.Confirmed && ReferenceEquals(actor,a.Controller))return;
|
||||
a.Cancelled=true;
|
||||
if(Ready && !a.Confirmed) Fault("Fishing reward cancelled: "+reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e000adddc1b4e5796d6728a1d142813
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8725a7f81e4971046acaa6c14a8f6f97
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,780 +0,0 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using GameCore;
|
||||
using Newtonsoft.Json;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal static partial class PlayerKVGameplayLoop
|
||||
{
|
||||
// Observes the original async-void method; never enables the temporary gameplay path.
|
||||
internal static async Task<string> VerifyOriginalFishingForEditorAsync()
|
||||
{
|
||||
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null)
|
||||
{
|
||||
throw new InvalidOperationException("Idle editor without a real permit required.");
|
||||
}
|
||||
if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("Validation running.");
|
||||
}
|
||||
|
||||
var oldContainer = GContext.container;
|
||||
var oldEvents = FishingContextAccess.Events;
|
||||
var oldLanguages = LocalizationMgr.enumLanguage;
|
||||
var fields = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
|
||||
var textTable = typeof(LocalizationMgr).GetField("table", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
|
||||
var oldTextTable = textTable.GetValue(null);
|
||||
var folder = Path.Combine(Root, "fishing-original-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
|
||||
var passed = new List<string>();
|
||||
int checks = 0;
|
||||
void Check(bool value, string message)
|
||||
{
|
||||
checks++;
|
||||
if (!value)
|
||||
{
|
||||
throw new InvalidOperationException("Original fishing: " + message);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
foreach (var scenario in new[] { "ordinary", "retry", "pack-21", "pack-22", "unauthorized", "held-recovery", "below-limit", "subscribers", "cross-limit-boundary" })
|
||||
{
|
||||
var errors = new List<Exception>();
|
||||
var fixture = new EquipmentFixture(Path.Combine(folder, scenario));
|
||||
KVPlayerSaveStore store = null;
|
||||
Container container = null;
|
||||
PlayerData player = null;
|
||||
AchievementDataManager achievements = null;
|
||||
FishingSubscribers subscribers = null;
|
||||
GameObject shell = null;
|
||||
IDisposable consumeSubscription = null;
|
||||
IDisposable playerSubscription = null;
|
||||
bool invoked = false;
|
||||
int completionLogs = 0;
|
||||
int saveFailureLogs = 0;
|
||||
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var unexpectedLogs = new List<string>();
|
||||
void Log(string condition, string stack, LogType type)
|
||||
{
|
||||
const string prefix = "[FishBehaviourB::ConsumeBaitOnPlayfab] ";
|
||||
if (condition == prefix + "True" || condition == prefix + "False")
|
||||
{
|
||||
completionLogs++;
|
||||
completed.TrySetResult(condition == prefix + "True");
|
||||
}
|
||||
if (scenario == "unauthorized" && type == LogType.Error && condition == "[KVMgr::SyncDataImmidiate] Save failed.")
|
||||
{
|
||||
saveFailureLogs++;
|
||||
}
|
||||
else if (type == LogType.Exception || type == LogType.Error || type == LogType.Assert)
|
||||
{
|
||||
unexpectedLogs.Add(condition + "\n" + stack);
|
||||
}
|
||||
}
|
||||
void Clean(Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(ex);
|
||||
validationCleanupFailure = fixture;
|
||||
}
|
||||
}
|
||||
void ObserveRecovery(Action startRecovery)
|
||||
{
|
||||
var awaitAssembly = typeof(IEnumeratorAwaitExtensions).Assembly;
|
||||
var staticFields = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic;
|
||||
var allFields = fields | System.Reflection.BindingFlags.Public;
|
||||
var syncField = awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil", true).GetField("<UnitySynchronizationContext>k__BackingField", staticFields);
|
||||
var runnerField = awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner", true).GetField("_instance", staticFields);
|
||||
var savedSync = syncField.GetValue(null);
|
||||
var savedRunner = runnerField.GetValue(null);
|
||||
var savedCurrent = SynchronizationContext.Current;
|
||||
var observer = new OriginalFishingTestContext();
|
||||
var scheduler = new OriginalFishingTestContext();
|
||||
System.Collections.IEnumerator instructionRun = null;
|
||||
IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter recoveryAwaiter = null;
|
||||
bool instructionYielded = false;
|
||||
bool instructionEnded = false;
|
||||
try
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(observer);
|
||||
syncField.SetValue(null, scheduler);
|
||||
startRecovery();
|
||||
Check(scheduler.Count == 1 && observer.Started == 1 && observer.Completed == 0, "original recovery suspended at one instruction");
|
||||
var posted = scheduler.Peek();
|
||||
Check(posted.State == null && posted.Callback.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass12_0", "exact original scheduler dispatch");
|
||||
var action = (Action)posted.Callback.Target.GetType().GetField("action", allFields).GetValue(posted.Callback.Target);
|
||||
Check(action.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass10_0" && action.Method.Name == "<GetAwaiterReturnVoid>b__0", "exact original void wait closure");
|
||||
recoveryAwaiter = (IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter)action.Target.GetType().GetField("awaiter", allFields).GetValue(action.Target);
|
||||
var instruction = action.Target.GetType().GetField("instruction", allFields).GetValue(action.Target);
|
||||
Check(instruction is WaitForSeconds && !recoveryAwaiter.IsCompleted, "actual incomplete seconds awaiter");
|
||||
var wrapper = typeof(IEnumeratorAwaitExtensions).GetNestedType("InstructionWrappers", System.Reflection.BindingFlags.NonPublic);
|
||||
instructionRun = (System.Collections.IEnumerator)wrapper.GetMethod("ReturnVoid", staticFields | System.Reflection.BindingFlags.Public).Invoke(null, new[] { (object)recoveryAwaiter, instruction });
|
||||
scheduler.Take();
|
||||
instructionYielded = instructionRun.MoveNext();
|
||||
Check(instructionYielded && ReferenceEquals(instructionRun.Current, instruction), "library iterator yielded original instruction");
|
||||
player.StopRecoveryEnergy();
|
||||
SynchronizationContext.SetSynchronizationContext(scheduler);
|
||||
instructionEnded = !instructionRun.MoveNext();
|
||||
Check(instructionEnded && recoveryAwaiter.IsCompleted && observer.Completed == 1, "manual-await-boundary exits original async void");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clean(() => player.StopRecoveryEnergy());
|
||||
Clean(() =>
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(scheduler);
|
||||
if (instructionRun != null && !instructionEnded)
|
||||
{
|
||||
if (!instructionYielded)
|
||||
{
|
||||
instructionYielded = instructionRun.MoveNext();
|
||||
}
|
||||
instructionEnded = !instructionRun.MoveNext();
|
||||
}
|
||||
});
|
||||
Clean(() =>
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(observer);
|
||||
while (observer.Count != 0)
|
||||
{
|
||||
var posted = observer.Take();
|
||||
Clean(() => posted.Callback(posted.State));
|
||||
}
|
||||
});
|
||||
Clean(() => Check(observer.Started == 1 && observer.Completed == 1 && observer.Count == 0 && scheduler.Count == 0 && instructionEnded && recoveryAwaiter != null && recoveryAwaiter.IsCompleted, "recovery state machine and queues drained"));
|
||||
Clean(() => Check(ReferenceEquals(runnerField.GetValue(null), savedRunner), "no coroutine runner created or replaced"));
|
||||
Clean(() => syncField.SetValue(null, savedSync));
|
||||
Clean(() => SynchronizationContext.SetSynchronizationContext(savedCurrent));
|
||||
}
|
||||
}
|
||||
Application.logMessageReceived += Log;
|
||||
try
|
||||
{
|
||||
var owner = await fixture.Open();
|
||||
store = new KVPlayerSaveStore();
|
||||
store.Bind(owner, CancellationToken.None);
|
||||
var tables = new cfg.Tables(n => SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData", n + ".json")))));
|
||||
Check(tables.TbEnergyDef.DataList.Count > 4 && tables.TbEnergyDef.DataList[4].Mag == 10 && tables.TbEnergyDef.DataList[4].RetryCost == 5, "distinct real normal/retry costs");
|
||||
container = new Container();
|
||||
FishingContextAccess.Use(container);
|
||||
FishingContextAccess.Events = new EventAggregator();
|
||||
container.RegisterInstance(tables);
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
player = new PlayerData(tables, null);
|
||||
var small = new SmallGameData(tables, null, null);
|
||||
var fish = new PlayerFishData(tables, null);
|
||||
achievements = new AchievementDataManager(tables);
|
||||
var events = new FishingEventData(tables);
|
||||
container.RegisterInstance(player);
|
||||
container.RegisterInstance(small);
|
||||
container.RegisterInstance(fish);
|
||||
container.RegisterInstance(achievements);
|
||||
container.RegisterInstance(new FishingBoxData());
|
||||
container.RegisterInstance(events);
|
||||
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
|
||||
player.playerSaveStore = store;
|
||||
fish.playerSaveStore = store;
|
||||
small.playerSaveStore = store;
|
||||
events.playerSaveStore = store;
|
||||
achievements.Init();
|
||||
LocalizationMgr.enumLanguage = tables.TbLanguageConfig.DataList.Select(x => x.ID).ToList();
|
||||
textTable.SetValue(null, null);
|
||||
int initialEnergy = scenario == "cross-limit-boundary" ? 300 : scenario == "held-recovery" || scenario == "below-limit" ? 250 : 400;
|
||||
int rod = tables.TbRodData.DataMap.Keys.First();
|
||||
int map = tables.TbMapData.DataMap.Keys.First();
|
||||
Module02OriginalLoadTests.LoadCore(player, new object[] { 1, 10UL, 0, initialEnergy, 5, 4, rod, new DateTime(2026, 1, 1), 0, 7, 10UL, 2, map, map, 0, 0, 0, 0, 0 });
|
||||
typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player, 300);
|
||||
typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player, 1);
|
||||
if (scenario == "subscribers")
|
||||
{
|
||||
subscribers = new FishingSubscribers();
|
||||
subscribers.Bind(tables, events, container);
|
||||
Check(!tables.TbFishingEvent.DataList.Any(e => e.ConditionList.Any(c => c.Condition == cfg.ConditionType.ConsumeEnergy)) && !tables.TbTriggerPackManager.DataList.Any(t => t.Condition == cfg.ConditionType.ConsumeEnergy), "real configuration excludes additional async event and pack activation");
|
||||
}
|
||||
player.SavePlayerData(false);
|
||||
int subtype = scenario == "pack-21" ? 21 : scenario == "pack-22" ? 22 : 1;
|
||||
var item = tables.TbItem.DataList.FirstOrDefault(x => x.SubType == subtype);
|
||||
Check(item != null, "real item subtype " + subtype);
|
||||
var data = new FishingData { fishItem = item, IsRetry = scenario == "retry" };
|
||||
int cost = scenario == "retry" ? 5 : 10;
|
||||
int energyRM = player.GetEnergyRM();
|
||||
var expected = new Dictionary<string, string>(player.PlayerDataDic);
|
||||
expected["energy"] = (initialEnergy - cost).ToString();
|
||||
string expectedJson = JsonConvert.SerializeObject(expected);
|
||||
var order = new List<string>();
|
||||
int consumed = 0;
|
||||
playerSubscription = GContext.OnEvent<PlayerData>().Subscribe(_ => order.Add("player"));
|
||||
consumeSubscription = GContext.OnEvent<ConditionTypeEvent>().Subscribe(e =>
|
||||
{
|
||||
if (e.type == cfg.ConditionType.ConsumeEnergy)
|
||||
{
|
||||
Check(player.Energy == initialEnergy - cost, "event observes deducted energy");
|
||||
order.Add("consume");
|
||||
consumed += e.count;
|
||||
store.Write("k", "event-" + e.count).RequireAccepted();
|
||||
}
|
||||
});
|
||||
if (scenario == "cross-limit-boundary")
|
||||
{
|
||||
ObserveRecovery(() => player.ReduceEnergy(cost, false));
|
||||
Check(player.Energy == 290 && player.GetEnergyRM() == energyRM + cost, "original crossing deducts once");
|
||||
Check(order.SequenceEqual(new[] { "player", "player" }) && consumed == 0, "crossing publishes twice without inventing consume event");
|
||||
var crossingData = new Dictionary<string, string>(player.PlayerDataDic);
|
||||
Check(crossingData["newData"].Split(',')[3] == "290" && DateTime.Parse(crossingData["newData"].Split(',')[7]) > new DateTime(2026, 1, 1) && !crossingData.ContainsKey("energy"), "original crossing save has updated time and energy");
|
||||
Check(fixture.Sets == 0 && fixture.Remote["NewPlayerDataDic"] == "original-save", "crossing uses ordinary save without immediate network");
|
||||
var flushed = await KVMgr.SyncKVAsync(owner);
|
||||
Check(flushed.TransportError == PlayerKVError.None && flushed.Blocked == 0 && flushed.Unknown == 0 && fixture.Sets == 1 && fixture.Auth == 1, "crossing ordinary save succeeds once");
|
||||
Check(fixture.Remote["NewPlayerDataDic"] == JsonConvert.SerializeObject(crossingData), "crossing exact original serialized snapshot");
|
||||
var reloaded = new PlayerData(tables, null);
|
||||
Module02OriginalLoadTests.LoadCore(reloaded, JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]));
|
||||
Check(reloaded.Energy == player.Energy && reloaded.equipRodID == player.equipRodID && (ulong)reloaded.gold == (ulong)player.gold, "crossing original parser retains energy rod and gold");
|
||||
}
|
||||
else
|
||||
{
|
||||
shell = new GameObject("KV original consumption fixed fixture");
|
||||
shell.SetActive(false);
|
||||
shell.hideFlags = HideFlags.HideAndDontSave;
|
||||
var behavior = shell.AddComponent<FishingBehaviorB>();
|
||||
typeof(FishingBehaviorB).GetField("_tables", fields).SetValue(behavior, tables);
|
||||
typeof(FishingBehaviorB).GetField("fishingData", fields).SetValue(behavior, data);
|
||||
typeof(FishingBehaviorB).GetField("saveStore", fields).SetValue(behavior, store);
|
||||
Dictionary<string, string> subscriberWrites = null;
|
||||
fixture.Unauthorized = scenario == "unauthorized";
|
||||
fixture.HoldSet = scenario == "held-recovery";
|
||||
invoked = true;
|
||||
typeof(FishingBehaviorB).GetMethod("ConsumeBaitOnPlayfab", fields).Invoke(behavior, null);
|
||||
Check(player.Energy == initialEnergy - cost && player.GetEnergyRM() == energyRM + cost, "one original local deduction");
|
||||
Check(consumed == (subtype == 1 ? cost : 0), "original pack exclusion");
|
||||
Check(order.SequenceEqual(subtype == 1 ? new[] { "player", "consume" } : new[] { "player" }), "publish before event");
|
||||
if (subscribers != null)
|
||||
{
|
||||
Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single() == cost && subscribers.Lucky.DicTasks.Values.Single() == cost && subscribers.Magic.DicTasks.Values.Single() == cost && subscribers.Mini.Data.Exp == cost, "four actual subscribers process one original consume event");
|
||||
Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy) == (ulong)cost, "actual achievement receives original event");
|
||||
subscriberWrites = new[] { "BattlePassRecordTask", "MiniBPKeyNew", "EventLuckGameRecord", "EventLuckMagicPlayfabData" }.ToDictionary(key => key, key => store.Read(key).Value);
|
||||
Check(subscriberWrites.Values.All(value => !string.IsNullOrEmpty(value)) && subscriberWrites.Keys.All(key => !fixture.Remote.ContainsKey(key)), "actual task payloads wait for ordinary save timing");
|
||||
}
|
||||
if (fixture.HoldSet)
|
||||
{
|
||||
Check(await Task.WhenAny(fixture.SetEntered.Task, Task.Delay(5000)) == fixture.SetEntered.Task, "held request entered");
|
||||
Check(!completed.Task.IsCompleted && completionLogs == 0, "async void returned while actual request pending");
|
||||
ObserveRecovery(() => player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed * 60));
|
||||
Check(player.Energy == initialEnergy - cost + 1, "real recovery proceeds during request");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
}
|
||||
Check(await Task.WhenAny(completed.Task, Task.Delay(5000)) == completed.Task, "actual completion log observed");
|
||||
Check(await completed.Task == !fixture.Unauthorized && completionLogs == 1, "original success/false result");
|
||||
Check(saveFailureLogs == (fixture.Unauthorized ? 1 : 0), "expected 401 save error is observed exactly once");
|
||||
Check(fixture.Sets == 1 && fixture.Auth == 1, "one immediate attempt and no reauthentication");
|
||||
Check(fixture.Remote["k"] == "original", "ordinary event key not sent by immediate write");
|
||||
if (fixture.Unauthorized)
|
||||
{
|
||||
Check(fixture.Remote["NewPlayerDataDic"] == "original-save", "401 did not commit snapshot");
|
||||
}
|
||||
else
|
||||
{
|
||||
Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "original exact pre-event snapshot plus energy");
|
||||
Check(expected["newData"].Split(',')[3] == initialEnergy.ToString(), "copied newData retains original value");
|
||||
if (subscribers != null)
|
||||
{
|
||||
var originalSnapshot = new PlayerData(tables, null);
|
||||
Module02OriginalLoadTests.LoadCore(originalSnapshot, JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]));
|
||||
Check(originalSnapshot.Energy == initialEnergy && originalSnapshot.equipRodID == player.equipRodID && (ulong)originalSnapshot.gold == (ulong)player.gold, "original parser reads pre-event snapshot values without canonical rewrite");
|
||||
}
|
||||
var flush = await KVMgr.SyncKVAsync(owner);
|
||||
Check(flush.TransportError == PlayerKVError.None && flush.Blocked == 0 && flush.Unknown == 0 && fixture.Remote["k"] == (subtype == 1 ? "event-" + cost : "original"), "subsequent ordinary event write");
|
||||
if (subscriberWrites != null)
|
||||
{
|
||||
Check(subscriberWrites.All(pair => fixture.Remote.TryGetValue(pair.Key, out var value) && value == pair.Value), "ordinary tick persists exact actual task payloads");
|
||||
Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "task ordinary save does not rewrite original immediate snapshot");
|
||||
}
|
||||
if (fixture.HoldSet)
|
||||
{
|
||||
var recovered = JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]);
|
||||
Check(recovered["newData"].Split(',')[3] == (initialEnergy - cost + 1).ToString() && !recovered.ContainsKey("energy"), "later recovery saved separately");
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(!Enabled && !Ready && handle == null, "no temporary gameplay activation");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
if (invoked && await Task.WhenAny(completed.Task, Task.Delay(5000)) != completed.Task)
|
||||
{
|
||||
errors.Add(new InvalidOperationException("Original async void did not drain: " + scenario));
|
||||
validationCleanupFailure = fixture;
|
||||
}
|
||||
Clean(() => player?.StopRecoveryEnergy());
|
||||
Clean(() => consumeSubscription?.Dispose());
|
||||
Clean(() => playerSubscription?.Dispose());
|
||||
Clean(() => subscribers?.Dispose());
|
||||
Clean(() => achievements?.Dispose());
|
||||
Clean(() => store?.Dispose());
|
||||
try
|
||||
{
|
||||
await fixture.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(ex);
|
||||
validationCleanupFailure = fixture;
|
||||
}
|
||||
Clean(() =>
|
||||
{
|
||||
if (shell != null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(shell);
|
||||
}
|
||||
});
|
||||
Clean(() => container?.Dispose());
|
||||
FishingContextAccess.Use(oldContainer);
|
||||
FishingContextAccess.Events = oldEvents;
|
||||
Application.logMessageReceived -= Log;
|
||||
if (unexpectedLogs.Count != 0)
|
||||
{
|
||||
errors.Add(new InvalidOperationException(string.Join("\n", unexpectedLogs)));
|
||||
}
|
||||
}
|
||||
if (errors.Count != 0)
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(Path.Combine(folder, "failure.json"), JsonConvert.SerializeObject(new { scenario, errors = errors.Select(x => x.ToString()), formalRequests = 0 }));
|
||||
throw new AggregateException(errors);
|
||||
}
|
||||
passed.Add(scenario);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
FishingContextAccess.Use(oldContainer);
|
||||
FishingContextAccess.Events = oldEvents;
|
||||
LocalizationMgr.enumLanguage = oldLanguages;
|
||||
try
|
||||
{
|
||||
textTable.SetValue(null, oldTextTable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref validationRunning, 0);
|
||||
}
|
||||
}
|
||||
string result = JsonConvert.SerializeObject(new { success = true, groups = passed.Count, assertions = checks, passed, recoveryTiming = "manual-await-boundary", formalRequests = 0, synthetic = true, restored = true, folder });
|
||||
Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(Path.Combine(folder, "result.json"), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class OriginalFishingTestContext : SynchronizationContext
|
||||
{
|
||||
private readonly Queue<(SendOrPostCallback Callback, object State)> posts = new Queue<(SendOrPostCallback, object)>();
|
||||
private int started;
|
||||
private int completed;
|
||||
|
||||
internal int Count => posts.Count;
|
||||
internal int Started => started;
|
||||
internal int Completed => completed;
|
||||
|
||||
public override void Post(SendOrPostCallback callback, object state)
|
||||
{
|
||||
posts.Enqueue((callback, state));
|
||||
}
|
||||
|
||||
public override void OperationStarted()
|
||||
{
|
||||
started++;
|
||||
}
|
||||
|
||||
public override void OperationCompleted()
|
||||
{
|
||||
completed++;
|
||||
}
|
||||
|
||||
internal (SendOrPostCallback Callback, object State) Peek()
|
||||
{
|
||||
return posts.Peek();
|
||||
}
|
||||
|
||||
internal (SendOrPostCallback Callback, object State) Take()
|
||||
{
|
||||
return posts.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
// Only a fixed idle-editor test; the handler inherited from EquipmentFixture never forwards requests.
|
||||
internal static async Task<string> VerifyFishingForEditorAsync()
|
||||
{
|
||||
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null)
|
||||
{
|
||||
throw new InvalidOperationException("Idle editor without a real permit required.");
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("Validation running.");
|
||||
}
|
||||
|
||||
var saved = (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction);
|
||||
var folder = Path.Combine(Root, "fishing-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
|
||||
EquipmentFixture fixture = null;
|
||||
Task pending = null;
|
||||
int checks = 0, groups = 0;
|
||||
var passed = new List<string>();
|
||||
string result = null;
|
||||
void Check(bool yes, string reason)
|
||||
{
|
||||
checks++;
|
||||
if (!yes)
|
||||
{
|
||||
throw new InvalidOperationException("Fishing validation: " + reason);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Start(string name)
|
||||
{
|
||||
if (fixture != null)
|
||||
{
|
||||
await fixture.Close();
|
||||
}
|
||||
|
||||
if (pending != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pending;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
pending = null;
|
||||
}
|
||||
|
||||
|
||||
fixture = new EquipmentFixture(Path.Combine(folder, (++groups).ToString()));
|
||||
var owner = await fixture.Open();
|
||||
Enabled = true;
|
||||
Ready = false;
|
||||
Error = null;
|
||||
failed = 0;
|
||||
handle = null;
|
||||
collecting = null;
|
||||
fishingAction = null;
|
||||
Permit = new PlayerKVLoopPermit
|
||||
{
|
||||
Version = 2,
|
||||
Operation = "fishing-consume-once",
|
||||
PlayerId = "101",
|
||||
AllowedKeys = new[]
|
||||
{
|
||||
"k",
|
||||
"NewPlayerDataDic",
|
||||
"late"
|
||||
}
|
||||
};
|
||||
RunPath = folder;
|
||||
mainThread = Thread.CurrentThread.ManagedThreadId;
|
||||
mainContext = SynchronizationContext.Current;
|
||||
Attach(_ => fixture.Faults++);
|
||||
Publish(owner);
|
||||
passed.Add(name);
|
||||
}
|
||||
|
||||
(FishingAction action, WriteBatch batch, PlayerKVEditResult primary) Batch(bool duplicates = false)
|
||||
{
|
||||
var action = BeginFishing(new object ());
|
||||
Check(ClaimFishingConsumption(action), "first consume admitted");
|
||||
var batch = BeginFishingWrites(action);
|
||||
if (duplicates)
|
||||
{
|
||||
Write("NewPlayerDataDic", "intermediate-local", false);
|
||||
Write("NewPlayerDataDic", "intermediate-remote", true);
|
||||
}
|
||||
|
||||
Write("k", "task", true);
|
||||
var primary = Write("NewPlayerDataDic", "final", true);
|
||||
batch.Dispose();
|
||||
return (action, batch, primary);
|
||||
}
|
||||
|
||||
async Task Reject(Task task, string label)
|
||||
{
|
||||
bool rejected = false;
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
Check(rejected && !Ready && fixture.Faults == 1, label);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
await Start("duplicate-state-writes-and-original-final-tickets");
|
||||
var b = Batch(true);
|
||||
Check(b.batch.Records.Count == 4 && !b.batch.Records[0].Remote && b.batch.Records[0].Edit.Ticket == null, "local intermediate captured");
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
await ConfirmFishingAsync(b.action, b.batch, b.primary);
|
||||
Check(fixture.Remote["NewPlayerDataDic"] == "final" && fixture.Remote["k"] == "task", "both final values persisted");
|
||||
int continued = 0;
|
||||
ContinueFishing(b.action, () => continued++);
|
||||
ContinueFishing(b.action, () => continued++);
|
||||
Check(continued == 1 && !ClaimFishingConsumption(b.action) && BeginFishing(new object ()) == null, "continue and consume/cast only once");
|
||||
await Start("pending-response-does-not-continue");
|
||||
b = Batch();
|
||||
fixture.HoldSet = true;
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
pending = ConfirmFishingAsync(b.action, b.batch, b.primary);
|
||||
Check(ReferenceEquals(await Task.WhenAny(fixture.SetEntered.Task, pending, Task.Delay(5000)), fixture.SetEntered.Task), "Set entered");
|
||||
Check(!b.action.Confirmed && !b.action.Continued, "no continuation during pending response");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
await pending;
|
||||
pending = null;
|
||||
Check(b.action.Confirmed, "original response confirms");
|
||||
await Start("final-localOnly-rejected-before-flush");
|
||||
b = Batch();
|
||||
// A sealed record ledger from actual writes with a localOnly final key.
|
||||
collecting = b.batch;
|
||||
Write("k", "local-tail", false);
|
||||
b.batch.Dispose();
|
||||
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "local final cannot report remote success");
|
||||
Check(fixture.Sets == 0, "no flush of rejected batch");
|
||||
await Start("external-final-supersession");
|
||||
b = Batch();
|
||||
Write("NewPlayerDataDic", "later-action", true);
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "never substitute later ticket");
|
||||
await Start("lost-response-after-commit");
|
||||
b = Batch();
|
||||
fixture.LoseResponse = true;
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "Unknown stops run");
|
||||
Check(fixture.Sets == 1 && fixture.Remote["NewPlayerDataDic"] == "final", "one uncertain commit, no replay");
|
||||
await Start("unauthorized-does-not-reauthenticate");
|
||||
b = Batch();
|
||||
fixture.Unauthorized = true;
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "401 stops run");
|
||||
Check(fixture.Auth == 1 && fixture.Sets == 1, "single auth and Set");
|
||||
await Start("cancelled-object-does-not-continue-after-commit");
|
||||
b = Batch();
|
||||
fixture.HoldSet = true;
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
pending = ConfirmFishingAsync(b.action, b.batch, b.primary);
|
||||
await fixture.SetEntered.Task;
|
||||
CancelFishing(b.action, "synthetic disable");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
await Reject(pending, "disabled original action cannot continue");
|
||||
pending = null;
|
||||
Check(!b.action.Continued && b.action.Cancelled, "no stale continuation");
|
||||
await Start("close-before-confirmation");
|
||||
b = Batch();
|
||||
await fixture.Close();
|
||||
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "closed owner rejected");
|
||||
Check(fixture.Sets == 0, "close does not invent flush");
|
||||
await Start("partial-commit-is-not-atomic-success");
|
||||
b = Batch();
|
||||
fixture.PartialCommit = true;
|
||||
fixture.LoseResponse = true;
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "partial unknown stops run");
|
||||
Check(fixture.Sets == 1 && fixture.Remote["k"] == "task" && fixture.Remote["NewPlayerDataDic"] == "original-save", "partial commit retained without compensation");
|
||||
await Start("storage-error-before-send");
|
||||
var stateFile = Directory.GetFiles(Path.Combine(folder, groups.ToString()), "state.json", SearchOption.AllDirectories).Single();
|
||||
using (var locked = new FileStream(stateFile, FileMode.Open, FileAccess.Read, FileShare.None))
|
||||
{
|
||||
bool storage = false;
|
||||
try
|
||||
{
|
||||
Write("NewPlayerDataDic", "locked", true);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
storage = e.Message.Contains("StorageError");
|
||||
}
|
||||
|
||||
Check(storage && !Ready && fixture.Faults == 1 && fixture.Sets == 0, "storage failure cannot be accepted or retried");
|
||||
}
|
||||
|
||||
await Start("observation-timeout-does-not-replay");
|
||||
b = Batch();
|
||||
fixture.HoldSet = true;
|
||||
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
|
||||
pending = ConfirmFishingAsync(b.action, b.batch, b.primary);
|
||||
await Reject(pending, "30 second observer deadline stops continuation");
|
||||
pending = null;
|
||||
Check(fixture.Sets == 1 && !b.action.Continued, "timeout leaves one admitted write");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
await Start("equipment-permit-cannot-cast");
|
||||
Permit.Version = 1;
|
||||
Permit.Operation = null;
|
||||
bool denied = false;
|
||||
try
|
||||
{
|
||||
BeginFishing(new object ());
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
denied = true;
|
||||
}
|
||||
|
||||
Check(denied && fixture.Sets == 0, "old permit cannot consume");
|
||||
result = JsonConvert.SerializeObject(new { success = true, groups, assertions = checks, passed, formalRequests = 0, synthetic = true, restored = true, folder });
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (fixture != null)
|
||||
{
|
||||
await fixture.Close();
|
||||
}
|
||||
|
||||
if (pending != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pending;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning("Observed fishing test failure: " + e.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
(Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction) = saved;
|
||||
}
|
||||
catch
|
||||
{
|
||||
validationCleanupFailure = fixture;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref validationRunning, 0);
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(folder, "result.json"), result);
|
||||
return result;
|
||||
}
|
||||
// Fixed real subscriber fixture. Reflection targets are compiled constants, never supplied by CLI.
|
||||
sealed class FishingSubscribers:IDisposable
|
||||
{
|
||||
const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic;
|
||||
static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
private readonly object oldRed = Red.GetValue(null);
|
||||
private LuckyTaskData magicTask;
|
||||
private MiniBattlePassDataProvider provider;
|
||||
internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky;
|
||||
internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini;
|
||||
internal void Bind(cfg.Tables tables,FishingEventData events,Container container)
|
||||
{
|
||||
Red.SetValue(null,new RedPointManager());
|
||||
var store=container.Resolve<IPlayerSaveStore>();
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
container.Resolve<PlayerData>().playerSaveStore=store;
|
||||
container.Resolve<PlayerFishData>().playerSaveStore=store;
|
||||
container.Resolve<SmallGameData>().playerSaveStore=store;
|
||||
events.playerSaveStore=store;
|
||||
var items=new PlayerItemData(tables);container.RegisterInstance(items);
|
||||
var data=new BattlePassData(tables){playerSaveStore=store};
|
||||
data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID;
|
||||
data.BattlePassRecord.Level=1;
|
||||
data.BattleTask.dic_dailyTasks.Add(tables.TbBattlePassTask.DataList.First(t=>t.Type==cfg.ConditionType.ConsumeEnergy).TaskID,0);
|
||||
Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle);
|
||||
var common=tables.TbCommonTask.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy).Id;
|
||||
Lucky=new EventLuckyGameModel(events,tables);Lucky.DicTasks.Add(common,0);
|
||||
Magic=new EventLuckMagicModel{DicTasks=new Dictionary<int,int>{{common,0}},HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()};
|
||||
var rings=(List<EventLuckMagicRing>)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic);
|
||||
for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i));
|
||||
magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask);
|
||||
provider=new MiniBattlePassDataProvider(tables,items);
|
||||
var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0);
|
||||
var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id);
|
||||
var type=(MiniBattlePassType)table.Type;
|
||||
Mini=provider.GetModel(type);
|
||||
provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID;
|
||||
typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity);
|
||||
typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id});
|
||||
// Synthetic fixture has an active window; real-run time is never modified.
|
||||
Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1);
|
||||
typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1));
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
var errors = new List<Exception>();
|
||||
void Release(Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
Release(() => Battle?.Dispose());
|
||||
Release(() => Lucky?.Dispose());
|
||||
Release(() => magicTask?.Dispose());
|
||||
Release(() =>
|
||||
{
|
||||
if (Mini != null)
|
||||
{
|
||||
(typeof(MiniBattlePassDataModel).GetField("disposables", Fields).GetValue(Mini) as IDisposable)?.Dispose();
|
||||
}
|
||||
});
|
||||
Release(() => Red.SetValue(null, oldRed));
|
||||
Release(() =>
|
||||
{
|
||||
if (!ReferenceEquals(Red.GetValue(null), oldRed))
|
||||
{
|
||||
throw new InvalidOperationException("Subscriber fixture statics did not restore.");
|
||||
}
|
||||
});
|
||||
if (errors.Count != 0)
|
||||
{
|
||||
throw new AggregateException(errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
sealed class FishingContextAccess:GContext
|
||||
{
|
||||
protected override Task SetupAsync()=>throw new InvalidOperationException("Test-only context.");
|
||||
internal static void Use(Container value) { container=value; }
|
||||
internal static IEventAggregator Events { get=>eventAggregator; set=>eventAggregator=value; }
|
||||
}
|
||||
sealed class FishingNotifications:ILocalNotificationService
|
||||
{
|
||||
public Task Init()=>Task.CompletedTask; public bool IsNoticeOpen=>false;
|
||||
public void OpenSettings(){} public void ScheduleOneTimeNotification(int id,DateTime time,string title,string text){}
|
||||
public void ScheduleDailyNotification(int id,DateTime time,string title,string text){}
|
||||
public void ClearAllNotification(){} public void ClearNotification(int id){} public void ScheduleDefaultNotification(){}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f0d0cfb141639484a8e9592320fd607a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,638 +0,0 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
#if !SERVICE_HOST
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using asap.core;
|
||||
using GameCore;
|
||||
using game.PlayerDataStorage;
|
||||
using UnityEngine;
|
||||
#endif
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Editor-only permission to use an existing, backed-up disposable save. Never a migration tool.
|
||||
internal sealed class PlayerKVLoopPermit
|
||||
{
|
||||
public int Version { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public string PlayerId { get; set; }
|
||||
public string Api { get; set; }
|
||||
public string BackupPath { get; set; }
|
||||
public string BackupSha256 { get; set; }
|
||||
public string[] AllowedKeys { get; set; }
|
||||
[JsonProperty(NullValueHandling=NullValueHandling.Ignore)]
|
||||
public string Operation { get; set; }
|
||||
[JsonIgnore] internal bool AllowsFishing => (Version==2 && Operation=="fishing-consume-once") || AllowsReward;
|
||||
[JsonIgnore] internal bool AllowsReward => Version==3 && Operation=="fishing-reward-once";
|
||||
internal static string Hash(byte[] bytes)
|
||||
{ using (var sha=SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-", ""); }
|
||||
internal static PlayerKVLoopPermit Read(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document=JObject.Parse(File.ReadAllText(path));
|
||||
var p=document.ToObject<PlayerKVLoopPermit>();
|
||||
if(p==null || (p.Version!=1 && p.Version!=2 && p.Version!=3) ||
|
||||
(p.Version==1 && document.Property("Operation",StringComparison.Ordinal)!=null) ||
|
||||
(p.Version==2 && p.Operation!="equipment" && p.Operation!="fishing-consume-once") ||
|
||||
(p.Version==3 && p.Operation!="fishing-reward-once") ||
|
||||
!p.Enabled || (p.PlayerId!="101" && p.PlayerId!="102") ||
|
||||
p.Api!="http://s101.local:18080/" || p.AllowedKeys==null || p.AllowedKeys.Length==0 ||
|
||||
p.AllowedKeys.Distinct(StringComparer.Ordinal).Count()!=p.AllowedKeys.Length ||
|
||||
!p.AllowedKeys.Contains("NewPlayerDataDic")) throw new InvalidOperationException("Invalid loop permission.");
|
||||
var root=Path.GetFullPath(Path.GetDirectoryName(path))+Path.DirectorySeparatorChar;
|
||||
var backup=Path.GetFullPath(p.BackupPath);
|
||||
if(!backup.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup must be inside loop workspace.");
|
||||
var bytes=File.ReadAllBytes(backup);
|
||||
if(!string.Equals(Hash(bytes),p.BackupSha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup hash mismatch.");
|
||||
var saved=JObject.Parse(Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF'));
|
||||
var data=saved["data"] as JObject;
|
||||
if((string)saved["playerId"]!=p.PlayerId || (string)saved["api"]!=p.Api || data==null ||
|
||||
p.AllowedKeys.Any(k=>string.IsNullOrEmpty(k) || k.Length>128 || data.Property(k,StringComparison.Ordinal)==null) ||
|
||||
data.Properties().Any(v=>v.Value.Type!=JTokenType.String)) throw new InvalidOperationException("Backup identity or key permission mismatch.");
|
||||
return p;
|
||||
}
|
||||
catch(InvalidOperationException) { throw; }
|
||||
catch(Exception e) { throw new InvalidOperationException("Cannot validate loop permission.",e); }
|
||||
}
|
||||
internal static string Claim(string root,string processIdentity)
|
||||
{
|
||||
if(string.IsNullOrEmpty(processIdentity) || processIdentity.Any(c=>!char.IsLetterOrDigit(c) && c!='-' && c!='_'))
|
||||
throw new InvalidOperationException("Invalid process identity.");
|
||||
var folder=Path.Combine(root,"run-control"); Directory.CreateDirectory(folder);
|
||||
var path=Path.Combine(folder,processIdentity+".claimed");
|
||||
using(var file=new FileStream(path,FileMode.CreateNew,FileAccess.Write,FileShare.Read))
|
||||
{ var bytes=Encoding.UTF8.GetBytes(DateTime.UtcNow.ToString("O")); file.Write(bytes,0,bytes.Length); file.Flush(true); }
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
#if !SERVICE_HOST
|
||||
internal static partial class PlayerKVGameplayLoop
|
||||
{
|
||||
internal static bool Enabled { get; private set; }
|
||||
internal static bool Ready { get; private set; }
|
||||
internal static string Error { get; private set; }
|
||||
internal static PlayerKVLoopPermit Permit { get; private set; }
|
||||
internal static string Root => Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests"));
|
||||
internal static string RunPath { get; private set; }
|
||||
static PlayerKVGameSessionHandle handle;
|
||||
static Action<string> onFault;
|
||||
static WriteBatch collecting;
|
||||
static int mainThread, failed;
|
||||
static SynchronizationContext mainContext;
|
||||
static readonly object auditGate=new object();
|
||||
|
||||
internal static void Attach(Action<string> fault)
|
||||
{
|
||||
onFault = fault;
|
||||
}
|
||||
internal static void Publish(PlayerKVGameSessionHandle value)
|
||||
{
|
||||
if(!Enabled || failed!=0 || handle!=null || value==null || value.PlayerId!=Permit.PlayerId)
|
||||
throw new InvalidOperationException("Loop handle cannot be published.");
|
||||
handle=value; Ready=true; Audit("ready",new{value.PlayerId,value.OwnerInstanceId,value.Epoch});
|
||||
}
|
||||
internal static PlayerKVGameSessionHandle Capture()
|
||||
{
|
||||
if(!Ready || failed!=0 || handle==null || Thread.CurrentThread.ManagedThreadId!=mainThread)
|
||||
throw Stop("Loop not writable on this thread or requires process restart.");
|
||||
return handle;
|
||||
}
|
||||
internal static string Read(string key)
|
||||
{
|
||||
var result=KVMgr.ReadKV(Capture(),key);
|
||||
if(!result.Success) throw Stop("Read: "+result.Error);
|
||||
return result.Found==true ? result.Value : null;
|
||||
}
|
||||
internal static PlayerKVEditResult Write(string key,string value,bool remote)
|
||||
{
|
||||
var owner=Capture();
|
||||
if(!Permit.AllowedKeys.Contains(key,StringComparer.Ordinal)) throw Stop("Write outside backed-up permission: "+key);
|
||||
CheckRewardWrite(key,value);
|
||||
var valueHash=value==null?null:PlayerKVLoopPermit.Hash(Encoding.UTF8.GetBytes(value));
|
||||
Audit("write-attempt",new{key,remote,valueHash});
|
||||
var result=KVMgr.WriteKV(owner,key,value,remote);
|
||||
if(!result.Accepted) throw Stop("Write: "+result.StateResult.Error+" uncertain="+result.StateResult.LocalCommitMayHaveSucceeded);
|
||||
if(collecting!=null)
|
||||
{
|
||||
collecting.Edits.Add(result);
|
||||
collecting.Records.Add(new WriteRecord(key,remote,result,valueHash));
|
||||
}
|
||||
Audit("accepted",new{key,remote,result.StateResult.AttemptSequence,result.StateResult.Revision});
|
||||
return result;
|
||||
}
|
||||
internal sealed class WriteBatch : IDisposable
|
||||
{
|
||||
internal readonly PlayerKVGameSessionHandle Owner;
|
||||
internal readonly List<PlayerKVEditResult> Edits=new List<PlayerKVEditResult>();
|
||||
internal readonly List<WriteRecord> Records=new List<WriteRecord>();
|
||||
internal WriteBatch(PlayerKVGameSessionHandle owner) { Owner=owner; }
|
||||
public void Dispose() { if(ReferenceEquals(collecting,this)) collecting=null; }
|
||||
}
|
||||
internal sealed class WriteRecord
|
||||
{
|
||||
internal readonly string Key,ValueHash;
|
||||
internal readonly bool Remote;
|
||||
internal readonly PlayerKVEditResult Edit;
|
||||
internal WriteRecord(string key,bool remote,PlayerKVEditResult edit,string valueHash) { Key=key; Remote=remote; Edit=edit; ValueHash=valueHash; }
|
||||
}
|
||||
internal sealed class FishingAction
|
||||
{
|
||||
internal readonly object Actor;
|
||||
internal readonly PlayerKVGameSessionHandle Owner;
|
||||
internal bool Consuming,WritesStarted,Cancelled,Confirmed,Continued;
|
||||
internal FishingAction(object actor,PlayerKVGameSessionHandle owner) { Actor=actor; Owner=owner; }
|
||||
}
|
||||
static FishingAction fishingAction;
|
||||
internal static FishingAction BeginFishing(object actor)
|
||||
{
|
||||
if(Permit?.AllowsFishing!=true) throw Stop("A fishing-consume-once permit is required before casting.");
|
||||
if(fishingAction!=null) { Audit("duplicate-suppressed",new{point="cast"}); return null; }
|
||||
return fishingAction=new FishingAction(actor,Capture());
|
||||
}
|
||||
internal static bool FishingCurrent(FishingAction action) => action!=null && ReferenceEquals(action,fishingAction) &&
|
||||
!action.Cancelled && Ready && failed==0 && ReferenceEquals(action.Owner,handle);
|
||||
internal static void CancelFishing(FishingAction action,string reason)
|
||||
{
|
||||
if(action==null || action.Cancelled) return;
|
||||
CancelReward(action.Actor,reason);
|
||||
action.Cancelled=true;
|
||||
if(Ready && !action.Continued) Fault("Fishing action cancelled: "+reason);
|
||||
}
|
||||
internal static bool ClaimFishingConsumption(FishingAction action)
|
||||
{
|
||||
if(!FishingCurrent(action)) throw Stop("Fishing action is no longer current.");
|
||||
if(action.Consuming) { Audit("duplicate-suppressed",new{point="consume"}); return false; }
|
||||
action.Consuming=true; return true;
|
||||
}
|
||||
internal static WriteBatch BeginFishingWrites(FishingAction action)
|
||||
{
|
||||
if(!FishingCurrent(action) || !action.Consuming || action.WritesStarted || collecting!=null || !ReferenceEquals(Capture(),action.Owner))
|
||||
throw Stop("Fishing write batch is not admissible.");
|
||||
action.WritesStarted=true;
|
||||
return collecting=new WriteBatch(action.Owner);
|
||||
}
|
||||
internal static async Task ConfirmFishingAsync(FishingAction action,WriteBatch batch,PlayerKVEditResult primary)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!FishingCurrent(action) || !ReferenceEquals(batch.Owner,action.Owner) || ReferenceEquals(collecting,batch))
|
||||
throw Stop("Fishing batch must be sealed on its original action.");
|
||||
var last=batch.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal);
|
||||
if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) ||
|
||||
last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Fishing final writes require original remote tickets.");
|
||||
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
foreach(var record in batch.Records)
|
||||
{
|
||||
var ticket=record.Edit.Ticket;
|
||||
if(ticket==null) continue; // Intermediate localOnly is not a remote acknowledgement.
|
||||
var final=last[record.Key].Edit.Ticket;
|
||||
if(ticket.OwnerInstanceId!=batch.Owner.OwnerInstanceId || ticket.Epoch!=batch.Owner.Epoch ||
|
||||
ticket.Key!=record.Key || final.OwnerInstanceId!=ticket.OwnerInstanceId || final.Epoch!=ticket.Epoch)
|
||||
throw Stop("Fishing ticket identity mismatch.");
|
||||
var result=await KVMgr.WaitForKVTicketAsync(batch.Owner,ticket,timeout.Token);
|
||||
if(!FishingCurrent(action)) throw Stop("Fishing action changed while waiting.");
|
||||
bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(ticket,final) && final.Sequence>ticket.Sequence;
|
||||
if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Fishing ticket: "+result.Status);
|
||||
}
|
||||
}
|
||||
action.Confirmed=true;
|
||||
Audit("fishing-consume-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash}),batch.Owner.OwnerInstanceId,batch.Owner.Epoch});
|
||||
}
|
||||
catch(Exception e) { Fault(e.Message); throw; }
|
||||
}
|
||||
internal static void ContinueFishing(FishingAction action,Action continuation)
|
||||
{
|
||||
if(!FishingCurrent(action) || !action.Confirmed) throw Stop("Fishing cannot continue before confirmation.");
|
||||
if(action.Continued) { Audit("duplicate-suppressed",new{point="continue"}); return; }
|
||||
action.Continued=true;
|
||||
continuation();
|
||||
Audit("fishing-consume-ready",new{action.Owner.OwnerInstanceId,action.Owner.Epoch});
|
||||
}
|
||||
internal static InvalidOperationException Stop(string message) { Fault(message); return new InvalidOperationException(message); }
|
||||
internal static void Fault(string message)
|
||||
{
|
||||
Ready=false;
|
||||
if(Interlocked.Exchange(ref failed,1)!=0) return;
|
||||
Error=message;
|
||||
try { Audit("fault",new{message}); } catch(Exception e) { Debug.LogException(e); }
|
||||
Debug.LogError("KV loop stopped: "+message);
|
||||
if(Thread.CurrentThread.ManagedThreadId==mainThread) onFault?.Invoke(message);
|
||||
else mainContext?.Post(_=>onFault?.Invoke(message),null);
|
||||
}
|
||||
internal static void Audit(string kind,object detail)
|
||||
{
|
||||
if(RunPath==null) return;
|
||||
lock(auditGate) File.AppendAllText(Path.Combine(RunPath,"events.jsonl"),JsonConvert.SerializeObject(new{at=DateTime.UtcNow,kind,detail})+Environment.NewLine);
|
||||
}
|
||||
|
||||
// Fixed, compiled Editor test. No caller-supplied URL, code, reflection target, or real run permit.
|
||||
static int validationRunning;
|
||||
static object validationCleanupFailure;
|
||||
internal static async Task<string> VerifyEquipmentForEditorAsync()
|
||||
{
|
||||
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null)
|
||||
{
|
||||
throw new InvalidOperationException("An idle editor without a real loop permit is required.");
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("Validation already running.");
|
||||
}
|
||||
|
||||
var original = (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext);
|
||||
var folder = Path.Combine(Root, "fixed-equipment-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
|
||||
EquipmentFixture fixture = null;
|
||||
Task pendingConfirmation = null;
|
||||
string result = null;
|
||||
int assertions = 0, groups = 0;
|
||||
WriteBatch BeginEquip()
|
||||
{
|
||||
if (Permit?.AllowsFishing == true)
|
||||
{
|
||||
throw Stop("Equipment is outside this fishing permit.");
|
||||
}
|
||||
if (collecting != null)
|
||||
{
|
||||
throw Stop("Reentrant equipment action.");
|
||||
}
|
||||
return collecting = new WriteBatch(Capture());
|
||||
}
|
||||
|
||||
async Task ConfirmEquipAsync(WriteBatch batch, PlayerKVEditResult primary)
|
||||
{
|
||||
if (primary?.Ticket == null || !batch.Edits.Contains(primary) || !ReferenceEquals(Capture(), batch.Owner))
|
||||
{
|
||||
throw Stop("Missing original equipment ticket.");
|
||||
}
|
||||
foreach (var edit in batch.Edits)
|
||||
{
|
||||
// localOnly is audited, never claimed as remotely confirmed.
|
||||
if (edit.Ticket == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var result = await KVMgr.WaitForKVTicketAsync(batch.Owner, edit.Ticket);
|
||||
if (result.Status != PlayerKVTicketStatus.Confirmed)
|
||||
{
|
||||
throw Stop("Equipment ticket: " + result.Status);
|
||||
}
|
||||
}
|
||||
if (!ReferenceEquals(Capture(), batch.Owner))
|
||||
{
|
||||
throw Stop("Equipment session changed.");
|
||||
}
|
||||
Audit("equipment-confirmed", new { tickets = batch.Edits.Count(e => e.Ticket != null) });
|
||||
}
|
||||
|
||||
void Check(bool ok, string reason)
|
||||
{
|
||||
assertions++;
|
||||
if (!ok)
|
||||
{
|
||||
throw new InvalidOperationException("Equipment validation: " + reason);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Start()
|
||||
{
|
||||
if (fixture != null)
|
||||
{
|
||||
await fixture.Close();
|
||||
}
|
||||
|
||||
fixture = new EquipmentFixture(Path.Combine(folder, groups.ToString()));
|
||||
var owner = await fixture.Open();
|
||||
Enabled = true;
|
||||
Ready = false;
|
||||
Error = null;
|
||||
failed = 0;
|
||||
handle = null;
|
||||
collecting = null;
|
||||
Permit = new PlayerKVLoopPermit
|
||||
{
|
||||
PlayerId = "101",
|
||||
AllowedKeys = new[]
|
||||
{
|
||||
"k",
|
||||
"NewPlayerDataDic",
|
||||
"late"
|
||||
}
|
||||
};
|
||||
RunPath = folder;
|
||||
mainThread = Thread.CurrentThread.ManagedThreadId;
|
||||
mainContext = SynchronizationContext.Current;
|
||||
Attach(_ => fixture.Faults++);
|
||||
Publish(owner);
|
||||
groups++;
|
||||
}
|
||||
|
||||
async Task Reject(Task task, string contains)
|
||||
{
|
||||
bool rejected = false;
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
rejected = e.Message.Contains(contains);
|
||||
}
|
||||
|
||||
Check(rejected, "expected rejection: " + contains);
|
||||
Check(!Ready && fixture.Faults == 1, "one fault revokes facade");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!PlayerKVGameLoadGuard.TryEnter(out var probe))
|
||||
{
|
||||
throw new InvalidOperationException("Load guard occupied.");
|
||||
}
|
||||
|
||||
probe.Dispose();
|
||||
Directory.CreateDirectory(folder);
|
||||
await Start();
|
||||
Check(Read("missing") == null, "valid missing key remains null");
|
||||
var batch = BeginEquip();
|
||||
// This nested callback represents the synchronous Publish subscriber in the equipment chain.
|
||||
Action nested = () => Write("k", "nested", true);
|
||||
nested();
|
||||
var primary = Write("NewPlayerDataDic", "whole-save", true);
|
||||
batch.Dispose();
|
||||
Check(batch.Edits.Count == 2 && ReferenceEquals(primary, batch.Edits[1]), "all original edits collected");
|
||||
Check(fixture.Sets == 0, "write acceptance is local only");
|
||||
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
|
||||
await ConfirmEquipAsync(batch, primary);
|
||||
foreach (var edit in batch.Edits)
|
||||
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed, "each original ticket confirmed");
|
||||
Check(fixture.Remote["k"] == "nested" && fixture.Remote["NewPlayerDataDic"] == "whole-save" && Ready && fixture.Faults == 0, "complete batch committed");
|
||||
await Start();
|
||||
batch = BeginEquip();
|
||||
var first = Write("k", "first", true);
|
||||
Write("k", "second", true);
|
||||
primary = Write("NewPlayerDataDic", "whole-save", true);
|
||||
batch.Dispose();
|
||||
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
|
||||
await Reject(ConfirmEquipAsync(batch, primary), "Superseded");
|
||||
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, first.Ticket)).Status == PlayerKVTicketStatus.Superseded, "original nested ticket never replaced");
|
||||
await Start();
|
||||
batch = BeginEquip();
|
||||
Write("k", "nested", true);
|
||||
primary = Write("NewPlayerDataDic", "watermark", true);
|
||||
batch.Dispose();
|
||||
fixture.HoldSet = true;
|
||||
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
|
||||
var confirmation = ConfirmEquipAsync(batch, primary);
|
||||
pendingConfirmation = confirmation;
|
||||
var entered = await Task.WhenAny(fixture.SetEntered.Task, confirmation, Task.Delay(5000));
|
||||
Check(ReferenceEquals(entered, fixture.SetEntered.Task), "first set entered");
|
||||
var late = Write("late", "later-write", true);
|
||||
Check(batch.Edits.Count == 2, "later write excluded from synchronous batch");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
await confirmation;
|
||||
pendingConfirmation = null;
|
||||
Check(!fixture.Remote.ContainsKey("late"), "flush watermark excludes later write");
|
||||
await KVMgr.SyncKVAsync(batch.Owner);
|
||||
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, late.Ticket)).Status == PlayerKVTicketStatus.Confirmed, "later write needs its own flush");
|
||||
await Start();
|
||||
batch = BeginEquip();
|
||||
primary = Write("NewPlayerDataDic", "unauthorized", true);
|
||||
batch.Dispose();
|
||||
fixture.Unauthorized = true;
|
||||
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
|
||||
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
|
||||
Check(fixture.Auth == 1 && fixture.Sets == 1, "401 never reauthenticates or retries");
|
||||
await Start();
|
||||
batch = BeginEquip();
|
||||
primary = Write("NewPlayerDataDic", "lost-response", true);
|
||||
batch.Dispose();
|
||||
fixture.LoseResponse = true;
|
||||
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
|
||||
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
|
||||
Check(fixture.Remote["NewPlayerDataDic"] == "lost-response" && fixture.Sets == 1, "unknown remote commit is not replayed");
|
||||
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, primary.Ticket)).Status == PlayerKVTicketStatus.Unknown, "unknown original ticket preserved");
|
||||
await Start();
|
||||
batch = BeginEquip();
|
||||
primary = Write("NewPlayerDataDic", "closed", true);
|
||||
batch.Dispose();
|
||||
await fixture.Close();
|
||||
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
|
||||
Check(fixture.Sets == 0, "close does not flush pending writes");
|
||||
await Start();
|
||||
bool denied = false;
|
||||
try
|
||||
{
|
||||
Write("unbacked-key", "x", true);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
denied = e.Message.Contains("permission");
|
||||
}
|
||||
|
||||
Check(denied && fixture.Sets == 0 && !Ready, "key permission checked before writes");
|
||||
await Start();
|
||||
await fixture.Close();
|
||||
denied = false;
|
||||
try
|
||||
{
|
||||
Read("k");
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
denied = true;
|
||||
}
|
||||
|
||||
Check(denied && !Ready, "stale read cannot become missing");
|
||||
var originalContainer = GContext.container;
|
||||
var container = new Container();
|
||||
try
|
||||
{
|
||||
var configPath = Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData"));
|
||||
var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath, name + ".json"))));
|
||||
container.RegisterInstance<cfg.Tables>(tables);
|
||||
EquipmentContextAccess.Use(container);
|
||||
using (container.InjectionScope())
|
||||
container.Register<EventShootingRangeData>().AsSingleton();
|
||||
var shooting = container.Resolve<EventShootingRangeData>();
|
||||
Check(shooting != null && ReferenceEquals(shooting, container.Resolve<EventShootingRangeData>()), "M24 container resolves one instance");
|
||||
shooting.LoadData("{\"EventId\":11,\"AmmoCount\":7}");
|
||||
var shootingData = JObject.FromObject(Module02OriginalLoadTests.Field<object>(shooting, "_data"));
|
||||
Check((int)shootingData["EventId"] == 11 && (int)shootingData["AmmoCount"] == 7, "M24 registered instance uses original load method");
|
||||
}
|
||||
finally
|
||||
{
|
||||
EquipmentContextAccess.Use(originalContainer);
|
||||
container.Dispose();
|
||||
}
|
||||
|
||||
Check(ReferenceEquals(GContext.container, originalContainer), "M24 global container restored");
|
||||
groups++;
|
||||
result = JsonConvert.SerializeObject(new { success = true, groups, assertions, formalRequests = 0, synthetic = true, restored = true, folder });
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (fixture != null)
|
||||
{
|
||||
await fixture.Close();
|
||||
}
|
||||
|
||||
if (pendingConfirmation != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pendingConfirmation;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning("Observed failed synthetic confirmation during cleanup: " + e.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
(Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext) = original;
|
||||
}
|
||||
catch
|
||||
{
|
||||
validationCleanupFailure = fixture;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref validationRunning, 0);
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(folder, "result.json"), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
sealed class EquipmentContextAccess:GContext
|
||||
{
|
||||
protected override Task SetupAsync() => throw new InvalidOperationException("Not a gameplay context.");
|
||||
internal static void Use(Container value) { container=value; }
|
||||
}
|
||||
internal static string InspectPFBackupForEditor(string path)
|
||||
{
|
||||
if (Enabled || validationRunning != 0 || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0)
|
||||
{
|
||||
throw new InvalidOperationException("Idle editor required for source inspection.");
|
||||
}
|
||||
|
||||
path = Path.GetFullPath(path);
|
||||
var allowed = Path.GetFullPath(Path.Combine(Root, "pf-source-backups")) + Path.DirectorySeparatorChar;
|
||||
if (!path.StartsWith(allowed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Expected local PF source backup.");
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var source = JObject.Parse(Encoding.UTF8.GetString(bytes));
|
||||
var id = (string)source["customId"];
|
||||
if ((string)source["source"] != "PlayFab" || (string)source["titleId"] != "108EE8" || (id != "101" && id != "102"))
|
||||
{
|
||||
throw new InvalidOperationException("Source identity mismatch.");
|
||||
}
|
||||
|
||||
var raw = source["data"].ToObject<Dictionary<string, string>>();
|
||||
var actual = Module02OriginalLoadTests.InspectRaw(raw);
|
||||
return JsonConvert.SerializeObject(new { sourceOnly = true, usesLocalUtc = true, customId = id, path, sha256 = PlayerKVLoopPermit.Hash(bytes), keys = raw.Count, originalLoad = JObject.Parse(actual), gameplayPublished = false, formalRequests = 0 });
|
||||
}
|
||||
sealed class EquipmentFixture
|
||||
{
|
||||
static readonly Uri Api=new Uri("http://kv-loop.invalid/");
|
||||
readonly string cache;
|
||||
readonly game.Account.S101AccountSession session;
|
||||
readonly PlayerKVGameSessionBridge bridge;
|
||||
internal readonly Dictionary<string,string> Remote=new Dictionary<string,string>{{"k","original"},{"NewPlayerDataDic","original-save"}};
|
||||
internal readonly TaskCompletionSource<bool> SetEntered=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal readonly TaskCompletionSource<bool> ReleaseSet=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal int Sets,Auth,Faults;
|
||||
internal bool Unauthorized,LoseResponse,HoldSet,PartialCommit;
|
||||
sealed class Handler:HttpMessageHandler
|
||||
{
|
||||
readonly Func<HttpRequestMessage,Task<HttpResponseMessage>> send;
|
||||
internal Handler(Func<HttpRequestMessage,Task<HttpResponseMessage>> send) { this.send=send; }
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token) => send(request);
|
||||
}
|
||||
static HttpResponseMessage Json(object body) => new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent(JsonConvert.SerializeObject(body))};
|
||||
internal EquipmentFixture(string cache)
|
||||
{
|
||||
this.cache=cache;
|
||||
session=new game.Account.S101AccountSession(Api,jar=>new Handler(request=>
|
||||
{
|
||||
if(request.RequestUri!=new Uri(Api,"account/Login")) throw new InvalidOperationException("Unexpected synthetic login target.");
|
||||
Auth++; jar.Add(Api,new Cookie("sid","101"));
|
||||
return Task.FromResult(Json(new{state=0,internalId=101,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"}));
|
||||
}));
|
||||
bridge=new PlayerKVGameSessionBridge(jar=>new Handler(async request=>
|
||||
{
|
||||
if(request.RequestUri.Host!=Api.Host || jar.GetCookies(Api)["sid"]?.Value!="101") throw new InvalidOperationException("Unexpected synthetic KV identity.");
|
||||
if(request.RequestUri.AbsolutePath=="/playerdata/Get") return Json(new{data=Remote});
|
||||
if(request.RequestUri.AbsolutePath!="/playerdata/Set") throw new InvalidOperationException("Unexpected synthetic operation.");
|
||||
Sets++; SetEntered.TrySetResult(true);
|
||||
if(HoldSet) await ReleaseSet.Task;
|
||||
if(Unauthorized) return new HttpResponseMessage(HttpStatusCode.Unauthorized);
|
||||
var body=JObject.Parse(await request.Content.ReadAsStringAsync());
|
||||
var values=((JObject)body["data"]).Properties();
|
||||
foreach(var pair in PartialCommit?values.Take(1):values) Remote[pair.Name]=(string)pair.Value;
|
||||
if(LoseResponse) throw new HttpRequestException("Synthetic response lost after commit.");
|
||||
return Json(new{});
|
||||
}));
|
||||
}
|
||||
internal async Task<PlayerKVGameSessionHandle> Open()
|
||||
{
|
||||
if(!(await session.LoginAsync("101")).Success) throw new InvalidOperationException("Synthetic login failed.");
|
||||
var opened=await bridge.OpenAsync(session,cache,"101",Api,1,e=>e==1);
|
||||
if(!opened.Success) throw new InvalidOperationException("Synthetic open failed: "+opened.Error);
|
||||
return opened.Handle;
|
||||
}
|
||||
internal async Task Close()
|
||||
{
|
||||
ReleaseSet.TrySetResult(true);
|
||||
Exception closeError = null;
|
||||
try
|
||||
{
|
||||
await bridge.CloseAsync();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
closeError = error;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
catch (Exception disposeError)
|
||||
{
|
||||
if (closeError != null)
|
||||
{
|
||||
throw new AggregateException(closeError, disposeError);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5209b70ed0644051842744ab39fdf2f4
|
||||
@@ -163,7 +163,7 @@ namespace game
|
||||
|
||||
internal string CaptureSaveBackend()
|
||||
{
|
||||
return config == null ? "playfab" : config.Get<string>("SaveBackend", "playfab");
|
||||
return game.KV.KvRuntimeSelection.CompiledBackend;
|
||||
}
|
||||
|
||||
internal bool RejectSaveBackend(string backend)
|
||||
@@ -520,7 +520,7 @@ namespace game
|
||||
}
|
||||
|
||||
|
||||
private S101AccountSession apiSession;
|
||||
private GameServerAccountSession apiSession;
|
||||
private Task<bool> apiLoginTask;
|
||||
private int apiLoginGeneration;
|
||||
|
||||
@@ -542,7 +542,7 @@ namespace game
|
||||
try
|
||||
{
|
||||
customServerMgr.ClearAccountSession();
|
||||
var session = new S101AccountSession(customServerMgr.ApiBaseUri);
|
||||
var session = new GameServerAccountSession(customServerMgr.ApiBaseUri);
|
||||
apiSession = session;
|
||||
var result = await session.LoginAsync(requestedId);
|
||||
if (generation != apiLoginGeneration || requestedId != CustomId)
|
||||
|
||||
Reference in New Issue
Block a user