feat(upserver): 接入认证与KV存档并完成限定钓鱼闭环
This commit is contained in:
105
Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs
Normal file
105
Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb1e30eab9b24686a5b5110bc68c19b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
144
Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs
Normal file
144
Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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();
|
||||
return new PlayerKVResult(PlayerKVError.None, status, ParseData(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
static Dictionary<string, string> ParseData(string text)
|
||||
{
|
||||
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>());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcdbe8122e0b4f9eb82d2d1579081a7d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// The lease-only capability remains source-compatible with the independent 4B1 regression host.
|
||||
// This separate capability supplies every dependency required by a complete controlled load.
|
||||
internal sealed class PlayerKVGameLoadCapability
|
||||
{
|
||||
internal PlayerKVIdentitySnapshot Identity { get; }
|
||||
internal PlayerKVSchemaSnapshot Snapshot { get; }
|
||||
internal Uri Api { get; }
|
||||
internal string CacheRoot { get; }
|
||||
internal int LoginObserveMs { get; }
|
||||
internal int QuitObserveMs { get; }
|
||||
internal Func<int,CancellationToken,Task> Delay { get; }
|
||||
internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; }
|
||||
readonly PlayerKVControlledLoadCapability auth,kv,lease;
|
||||
readonly object gate=new object();
|
||||
CookieContainer cookies;
|
||||
HttpMessageHandler pendingKV,pendingLease;
|
||||
Exception cleanupFailure;
|
||||
readonly System.Collections.Generic.List<HttpMessageHandler> failedHandlers=new System.Collections.Generic.List<HttpMessageHandler>();
|
||||
bool claimed;
|
||||
private PlayerKVGameLoadCapability(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,Uri api,string cacheRoot,
|
||||
int loginMs,int quitMs,Func<int,CancellationToken,Task> delay,
|
||||
PlayerKVControlledLoadCapability auth,PlayerKVControlledLoadCapability kv,PlayerKVControlledLoadCapability lease)
|
||||
{
|
||||
Identity=identity ?? throw new ArgumentNullException(nameof(identity)); Snapshot=snapshot ?? throw new ArgumentNullException(nameof(snapshot));
|
||||
if(api==null || !api.IsAbsoluteUri || (api.Scheme!="http" && api.Scheme!="https") || !string.IsNullOrEmpty(api.UserInfo) ||
|
||||
!string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) || string.IsNullOrEmpty(cacheRoot) ||
|
||||
loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000) throw new ArgumentException("Controlled load configuration invalid.");
|
||||
Api=new Uri(api.AbsoluteUri.TrimEnd('/')+"/"); CacheRoot=cacheRoot;
|
||||
LoginObserveMs=loginMs; QuitObserveMs=quitMs; Delay=delay ?? Task.Delay; this.auth=auth; this.kv=kv; this.lease=lease;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVGameLoadCapability CreateForValidation(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,
|
||||
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv,
|
||||
Func<CookieContainer,HttpMessageHandler> lease,int loginMs=10000,int quitMs=5000,Func<int,CancellationToken,Task> delay=null)
|
||||
{
|
||||
var capability=new PlayerKVGameLoadCapability(identity,snapshot,api,cacheRoot,loginMs,quitMs,delay,
|
||||
PlayerKVControlledLoadCapability.CreateForValidation(auth),PlayerKVControlledLoadCapability.CreateForValidation(kv),
|
||||
PlayerKVControlledLoadCapability.CreateForValidation(lease));
|
||||
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
|
||||
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));
|
||||
}
|
||||
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=auth.CreateHandler(jar); pendingKV=kv.CreateHandler(jar); pendingLease=lease.CreateHandler(jar);
|
||||
if(ReferenceEquals(authentication,pendingKV) || ReferenceEquals(authentication,pendingLease) || ReferenceEquals(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89934f8eb7b441bcb60409e83a018b76
|
||||
969
Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs
Normal file
969
Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs
Normal file
@@ -0,0 +1,969 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVSchemaVerdict { Valid, EmptyNew, EmptyExisting, MissingCore, Malformed, ConflictingRepresentations, UnsupportedSchema, Stale }
|
||||
internal sealed class PlayerKVProgressPackPair
|
||||
{
|
||||
internal PlayerKVProgressPackState Current { get; }
|
||||
internal PlayerKVProgressPackState Migration { get; }
|
||||
internal PlayerKVProgressPackPair(PlayerKVProgressPackState current,PlayerKVProgressPackState migration)
|
||||
{ Current=current; Migration=migration; }
|
||||
}
|
||||
internal sealed class PlayerKVDecodedSave
|
||||
{
|
||||
readonly object[] modules;
|
||||
internal string PlayerId { get; }
|
||||
internal long Epoch { get; }
|
||||
internal string ConfigurationVersion { get; }
|
||||
internal IReadOnlyDictionary<string,string> Raw { get; }
|
||||
internal int ModuleCount => modules.Length;
|
||||
internal int UnconsumedTopLevelCount { get; }
|
||||
internal int UnconsumedModernCount { get; }
|
||||
internal int UnconsumedCount => UnconsumedTopLevelCount+UnconsumedModernCount;
|
||||
internal PlayerKVDecodedSave(string playerId,long epoch,string version,IReadOnlyDictionary<string,string> raw,object[] modules,int unconsumedTop=0,int unconsumedModern=0)
|
||||
{
|
||||
if(modules==null || modules.Length!=31 || modules.Any(m=>m==null)) throw new ArgumentException("Complete decoded save required.");
|
||||
PlayerId=playerId; Epoch=epoch; ConfigurationVersion=version;
|
||||
Raw=new ReadOnlyDictionary<string,string>(raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal));
|
||||
this.modules=(object[])modules.Clone();
|
||||
UnconsumedTopLevelCount=unconsumedTop; UnconsumedModernCount=unconsumedModern;
|
||||
}
|
||||
internal object Module(int id) { if(id<1 || id>31) throw new ArgumentOutOfRangeException(nameof(id)); return modules[id-1]; }
|
||||
internal T Module<T>(int id) where T:class => Module(id) as T ?? throw new InvalidOperationException("Module state mismatch.");
|
||||
}
|
||||
internal sealed class PlayerKVSchemaResult
|
||||
{
|
||||
internal PlayerKVSchemaVerdict Verdict { get; }
|
||||
internal PlayerKVSchemaDiagnostics Diagnostics { get; }
|
||||
internal PlayerKVDecodedSave Data { get; }
|
||||
internal PlayerKVSchemaResult(PlayerKVSchemaVerdict verdict,PlayerKVSchemaDiagnostics diagnostics,PlayerKVDecodedSave data=null)
|
||||
{ Verdict=verdict; Diagnostics=diagnostics; Data=verdict==PlayerKVSchemaVerdict.Valid ? data : null; }
|
||||
}
|
||||
internal enum PlayerKVIdentityChannel { hwGoogle, hwFacebook, hwGuest, hwVkid, Apple, none }
|
||||
internal sealed class PlayerKVIdentitySnapshot
|
||||
{
|
||||
internal string UserId { get; }
|
||||
internal bool NewlyCreated { get; }
|
||||
internal PlayerKVIdentityChannel Channel { get; }
|
||||
internal IReadOnlyList<PlayerKVIdentityChannel> Linked { get; }
|
||||
internal PlayerKVIdentitySnapshot(string userId,bool newlyCreated,PlayerKVIdentityChannel channel,IEnumerable<PlayerKVIdentityChannel> linked)
|
||||
{
|
||||
if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel) || linked==null) throw new ArgumentException("Identity snapshot required.");
|
||||
var copy=linked.Distinct().ToList();
|
||||
if(copy.Any(c=>!Enum.IsDefined(typeof(PlayerKVIdentityChannel),c))) throw new ArgumentException("Unknown identity channel.");
|
||||
UserId=userId; NewlyCreated=newlyCreated; Channel=channel; Linked=copy.AsReadOnly();
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVLoginState
|
||||
{
|
||||
internal int MMTest { get; }
|
||||
internal int TapJoyBalance { get; }
|
||||
internal IReadOnlyDictionary<string,string> AccountUrls { get; }
|
||||
internal IReadOnlyDictionary<string,string> Normalizations { get; }
|
||||
internal IReadOnlyList<PlayerKVIdentityChannel> Linked { get; }
|
||||
internal PlayerKVIdentityChannel Channel { get; }
|
||||
internal PlayerKVLoginState(int mm,int balance,IDictionary<string,string> urls,IDictionary<string,string> normalizations,
|
||||
IEnumerable<PlayerKVIdentityChannel> linked,PlayerKVIdentityChannel channel)
|
||||
{
|
||||
MMTest=mm; TapJoyBalance=balance;
|
||||
AccountUrls=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(urls,StringComparer.Ordinal));
|
||||
Normalizations=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(normalizations,StringComparer.Ordinal));
|
||||
Linked=linked.Distinct().ToList().AsReadOnly(); Channel=channel;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVPartnerBot
|
||||
{
|
||||
internal string Id { get; }
|
||||
internal int ScorePlayer { get; }
|
||||
internal int ScoreBot { get; }
|
||||
internal int Grade { get; }
|
||||
internal int TargetScore { get; }
|
||||
internal DateTime WakeStartTime { get; }
|
||||
internal int WakingSeconds { get; }
|
||||
internal PlayerKVPartnerBot(string id,int player,int bot,int grade,int target,DateTime wake,int seconds)
|
||||
{ Id=id; ScorePlayer=player; ScoreBot=bot; Grade=grade; TargetScore=target; WakeStartTime=wake; WakingSeconds=seconds; }
|
||||
}
|
||||
internal sealed class PlayerKVPartnerState
|
||||
{
|
||||
internal bool Present { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal int EventId { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal int CurrentPoints { get; }
|
||||
internal int CurrentMiningProgress { get; }
|
||||
internal int Token { get; }
|
||||
internal int InvitedCount { get; }
|
||||
internal int TicketCount { get; }
|
||||
internal IReadOnlyList<IReadOnlyList<int>> CompletedNodesList { get; }
|
||||
internal IReadOnlyList<(int myScore,int FriendScore)> BuilderPartnerScore { get; }
|
||||
internal float BonusPercent { get; }
|
||||
internal DateTime BonusExpireUtc { get; }
|
||||
internal int FreeSpinNum { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVPartnerBot> Bots { get; }
|
||||
internal bool IsRecvFinalReward { get; }
|
||||
internal IReadOnlyList<bool> UIFinishedPartners { get; }
|
||||
internal bool IsFinished { get; }
|
||||
internal int SpinState { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal bool IsEnableAutoMatch { get; }
|
||||
internal DateTime LastAutoMatchTime { get; }
|
||||
internal PlayerKVPartnerState(bool present,bool firstOpen,int eventId,DateTime start,DateTime end,
|
||||
int points,int progress,int token,int invited,int tickets,IEnumerable<IEnumerable<int>> nodes,
|
||||
IEnumerable<(int,int)> scores,float bonus,DateTime expires,int free,IDictionary<int,PlayerKVPartnerBot> bots,
|
||||
bool reward,IEnumerable<bool> ui,bool finished,int spin,float inflation,bool autoMatch,DateTime lastMatch)
|
||||
{
|
||||
Present=present; FirstOpen=firstOpen; EventId=eventId; StartTime=start; EndTime=end;
|
||||
CurrentPoints=points; CurrentMiningProgress=progress; Token=token; InvitedCount=invited; TicketCount=tickets;
|
||||
CompletedNodesList=nodes.Select(n=>(IReadOnlyList<int>)new List<int>(n).AsReadOnly()).ToList().AsReadOnly();
|
||||
BuilderPartnerScore=new List<(int,int)>(scores).AsReadOnly(); BonusPercent=bonus; BonusExpireUtc=expires;
|
||||
FreeSpinNum=free; Bots=new ReadOnlyDictionary<int,PlayerKVPartnerBot>(new Dictionary<int,PlayerKVPartnerBot>(bots));
|
||||
IsRecvFinalReward=reward; UIFinishedPartners=new List<bool>(ui).AsReadOnly(); IsFinished=finished;
|
||||
SpinState=spin; InflationRate=inflation; IsEnableAutoMatch=autoMatch; LastAutoMatchTime=lastMatch;
|
||||
}
|
||||
internal static PlayerKVPartnerState Missing() => new PlayerKVPartnerState(false,false,0,default,default,
|
||||
0,0,0,0,0,Array.Empty<int[]>(),Array.Empty<(int,int)>(),0,default,0,new Dictionary<int,PlayerKVPartnerBot>(),
|
||||
false,Array.Empty<bool>(),false,0,0,false,default);
|
||||
}
|
||||
internal enum PlayerKVSchemaError { Malformed, MissingCore, ConflictingRepresentations, UnsupportedSchema }
|
||||
|
||||
internal sealed class PlayerKVSchemaException : Exception
|
||||
{
|
||||
internal PlayerKVSchemaError Code { get; }
|
||||
internal PlayerKVSchemaException(PlayerKVSchemaError code = PlayerKVSchemaError.Malformed)
|
||||
: base("KV schema rejected.") { Code = code; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVSchemaDetail
|
||||
{
|
||||
internal int Module { get; }
|
||||
internal int Field { get; }
|
||||
internal PlayerKVSchemaError Code { get; }
|
||||
internal PlayerKVSchemaDetail(int module, int field, PlayerKVSchemaError code)
|
||||
{ Module = module; Field = field; Code = code; }
|
||||
}
|
||||
|
||||
// Values, save keys and exception messages are deliberately absent from diagnostic output.
|
||||
internal sealed class PlayerKVSchemaDiagnostics
|
||||
{
|
||||
readonly List<PlayerKVSchemaDetail> details = new List<PlayerKVSchemaDetail>();
|
||||
readonly SortedDictionary<int, Dictionary<PlayerKVSchemaError, int>> counts =
|
||||
new SortedDictionary<int, Dictionary<PlayerKVSchemaError, int>>();
|
||||
internal int TotalErrors { get; private set; }
|
||||
internal int OmittedDetails => TotalErrors - details.Count;
|
||||
internal IReadOnlyList<PlayerKVSchemaDetail> Details => details.AsReadOnly();
|
||||
internal int Count(int module, PlayerKVSchemaError code) =>
|
||||
counts.TryGetValue(module, out var codes) && codes.TryGetValue(code, out var count) ? count : 0;
|
||||
internal void Add(int module, int field, PlayerKVSchemaError code)
|
||||
{
|
||||
if (module < 1 || module > 31 || field < 0) throw new ArgumentOutOfRangeException();
|
||||
TotalErrors++;
|
||||
if (!counts.TryGetValue(module, out var codes)) counts.Add(module, codes = new Dictionary<PlayerKVSchemaError, int>());
|
||||
codes[code] = Count(module, code) + 1;
|
||||
// Only retain 32 items, even when callers visit representations in different orders.
|
||||
var item = new PlayerKVSchemaDetail(module, field, code);
|
||||
int index = details.FindIndex(x => x.Module > module || (x.Module == module && x.Field > field));
|
||||
if (index < 0) index = details.Count;
|
||||
if (index < 32) details.Insert(index, item);
|
||||
if (details.Count > 32) details.RemoveAt(32);
|
||||
}
|
||||
internal T Capture<T>(int module, int field, Func<T> parse)
|
||||
{
|
||||
try { return parse(); }
|
||||
catch (PlayerKVSchemaException ex) { Add(module, field, ex.Code); return default; }
|
||||
}
|
||||
}
|
||||
|
||||
// Captured copies only: decoding never resolves Unity services or reads current culture/time.
|
||||
internal sealed class PlayerKVSchemaSnapshot
|
||||
{
|
||||
readonly HashSet<int> rods, maps;
|
||||
readonly IReadOnlyDictionary<int, int> packTypes;
|
||||
readonly IReadOnlyDictionary<int, PlayerKVEventConfig> events;
|
||||
readonly IReadOnlyDictionary<string, int> conditions;
|
||||
readonly HashSet<int> conditionValues;
|
||||
readonly IReadOnlyDictionary<int,PlayerKVFishConfig> fish;
|
||||
readonly IReadOnlyDictionary<int,PlayerKVFishCardConfig> cards;
|
||||
internal string Version { get; }
|
||||
internal CultureInfo Culture { get; }
|
||||
internal DateTime NowUtc { get; }
|
||||
internal DateTime NowGame { get; }
|
||||
internal int InitWheelTicket { get; }
|
||||
internal PlayerKVSchemaSnapshot(string version, IEnumerable<int> rods, IEnumerable<int> maps,
|
||||
IDictionary<string, int> conditions, CultureInfo culture, DateTime nowUtc, int initWheelTicket,
|
||||
IDictionary<int, int> packTypes = null, IDictionary<int, PlayerKVEventConfig> events = null, int globalTimeOffset = 0,
|
||||
IDictionary<int,PlayerKVFishConfig> fish = null, IDictionary<int,PlayerKVFishCardConfig> cards = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version) || rods == null || maps == null || conditions == null || culture == null)
|
||||
throw new ArgumentException("Schema snapshot required.");
|
||||
Version = version; this.rods = new HashSet<int>(rods); this.maps = new HashSet<int>(maps);
|
||||
this.packTypes = new ReadOnlyDictionary<int, int>(new Dictionary<int, int>(packTypes ?? new Dictionary<int, int>()));
|
||||
this.events = new ReadOnlyDictionary<int, PlayerKVEventConfig>(new Dictionary<int, PlayerKVEventConfig>(events ?? new Dictionary<int, PlayerKVEventConfig>()));
|
||||
this.conditions = new ReadOnlyDictionary<string, int>(new Dictionary<string, int>(conditions, StringComparer.Ordinal));
|
||||
conditionValues = new HashSet<int>(conditions.Values);
|
||||
Culture = CultureInfo.ReadOnly((CultureInfo)culture.Clone()); NowUtc = nowUtc; InitWheelTicket = initWheelTicket;
|
||||
NowGame = nowUtc.AddHours(-(double)globalTimeOffset);
|
||||
this.fish=new ReadOnlyDictionary<int,PlayerKVFishConfig>(new Dictionary<int,PlayerKVFishConfig>(fish ?? new Dictionary<int,PlayerKVFishConfig>()));
|
||||
this.cards=new ReadOnlyDictionary<int,PlayerKVFishCardConfig>(new Dictionary<int,PlayerKVFishCardConfig>(cards ?? new Dictionary<int,PlayerKVFishCardConfig>()));
|
||||
}
|
||||
internal PlayerKVFishConfig Fish(int id) => fish.TryGetValue(id,out var value) ? value : null;
|
||||
internal PlayerKVFishCardConfig Card(int id) => cards.TryGetValue(id,out var value) ? value : null;
|
||||
internal bool HasRod(int id) => rods.Contains(id);
|
||||
internal bool HasPack(int id) => packTypes.ContainsKey(id);
|
||||
internal PlayerKVEventConfig Event(int id) => events.TryGetValue(id,out var value) ? value : null;
|
||||
internal bool HasPackType(int id, int type) => packTypes.TryGetValue(id, out var actual) && actual == type;
|
||||
internal bool HasMap(int id) => maps.Contains(id);
|
||||
internal int Condition(string text)
|
||||
{
|
||||
if (conditions.TryGetValue(text, out var named)) return named;
|
||||
if (int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var numeric)
|
||||
&& conditionValues.Contains(numeric)) return numeric;
|
||||
throw new PlayerKVSchemaException();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVCoreState
|
||||
{
|
||||
readonly object[] slots;
|
||||
internal PlayerKVCoreState(object[] slots) { this.slots = (object[])slots.Clone(); }
|
||||
internal int Integer(int slot) => (int)slots[slot];
|
||||
internal ulong Unsigned(int slot) => (ulong)slots[slot];
|
||||
internal DateTime EnergyTime => (DateTime)slots[7];
|
||||
internal bool Matches(int slot, object value) => Equals(slots[slot], value);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVAchievementValue
|
||||
{
|
||||
internal int Id { get; }
|
||||
internal ulong Target { get; }
|
||||
internal int Day { get; }
|
||||
internal PlayerKVAchievementValue(int id, ulong target, int day) { Id = id; Target = target; Day = day; }
|
||||
internal bool Same(PlayerKVAchievementValue other) => Id == other.Id && Target == other.Target && Day == other.Day;
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVAchievementState
|
||||
{
|
||||
internal IReadOnlyDictionary<int, PlayerKVAchievementValue> Values { get; }
|
||||
internal PlayerKVAchievementState(IDictionary<int, PlayerKVAchievementValue> values)
|
||||
{ Values = new ReadOnlyDictionary<int, PlayerKVAchievementValue>(new Dictionary<int, PlayerKVAchievementValue>(values)); }
|
||||
}
|
||||
|
||||
|
||||
internal sealed class PlayerKVPiggyBankState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int currentEventID { get; }
|
||||
internal int purchaseCount { get; }
|
||||
internal int VIPLvlWhenEventActivate { get; }
|
||||
internal int savingProgress { get; }
|
||||
internal int redirectID { get; }
|
||||
internal bool doNeedTrigger { get; }
|
||||
internal bool isTriggered { get; }
|
||||
internal float inflation { get; }
|
||||
internal PlayerKVPiggyBankState(bool present, int currentEventID, int purchaseCount, int VIPLvlWhenEventActivate, int savingProgress, int redirectID, bool doNeedTrigger, bool isTriggered, float inflation)
|
||||
{
|
||||
IsPresent = present;
|
||||
this.currentEventID = currentEventID;
|
||||
this.purchaseCount = purchaseCount;
|
||||
this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate;
|
||||
this.savingProgress = savingProgress;
|
||||
this.redirectID = redirectID;
|
||||
this.doNeedTrigger = doNeedTrigger;
|
||||
this.isTriggered = isTriggered;
|
||||
this.inflation = inflation;
|
||||
}
|
||||
internal static PlayerKVPiggyBankState Missing() => new PlayerKVPiggyBankState(false, 0, 0, 0, 0, 0, false, false, 0);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVBargainState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int currentEventID { get; }
|
||||
internal int redirectID { get; }
|
||||
internal int VIPLvlWhenEventActivate { get; }
|
||||
internal int purchaseCount { get; }
|
||||
internal int savingProgress { get; }
|
||||
internal int discountLvl { get; }
|
||||
internal int visualProgress { get; }
|
||||
internal int visualDiscountLvl { get; }
|
||||
internal int rodID { get; }
|
||||
internal int lastPackIDBought { get; }
|
||||
internal int packID { get; }
|
||||
internal bool doNeedTrigger { get; }
|
||||
internal bool isTriggered { get; }
|
||||
internal float inflation { get; }
|
||||
internal IReadOnlyList<int> selections { get; }
|
||||
internal PlayerKVBargainState(bool present, int currentEventID, int redirectID, int VIPLvlWhenEventActivate, int purchaseCount, int savingProgress, int discountLvl, int visualProgress, int visualDiscountLvl, int rodID, int lastPackIDBought, int packID, bool doNeedTrigger, bool isTriggered, float inflation, IEnumerable<int> selections)
|
||||
{
|
||||
IsPresent = present;
|
||||
this.currentEventID = currentEventID;
|
||||
this.redirectID = redirectID;
|
||||
this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate;
|
||||
this.purchaseCount = purchaseCount;
|
||||
this.savingProgress = savingProgress;
|
||||
this.discountLvl = discountLvl;
|
||||
this.visualProgress = visualProgress;
|
||||
this.visualDiscountLvl = visualDiscountLvl;
|
||||
this.rodID = rodID;
|
||||
this.lastPackIDBought = lastPackIDBought;
|
||||
this.packID = packID;
|
||||
this.doNeedTrigger = doNeedTrigger;
|
||||
this.isTriggered = isTriggered;
|
||||
this.inflation = inflation;
|
||||
this.selections = selections == null ? null : new List<int>(selections).AsReadOnly();
|
||||
}
|
||||
internal static PlayerKVBargainState Missing() => new PlayerKVBargainState(false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, false, 0, Array.Empty<int>());
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVShootingRangeState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventId { get; }
|
||||
internal int CycleId { get; }
|
||||
internal int RedirectId { get; }
|
||||
internal int AmmoCount { get; }
|
||||
internal int CurrentStageIdx { get; }
|
||||
internal int ChainProgress { get; }
|
||||
internal long RewardFlag { get; }
|
||||
internal long TargetFlag { get; }
|
||||
internal bool HasBeenOpened { get; }
|
||||
internal float Inflation { get; }
|
||||
internal PlayerKVShootingRangeState(bool present, int EventId, int CycleId, int RedirectId, int AmmoCount, int CurrentStageIdx, int ChainProgress, long RewardFlag, long TargetFlag, bool HasBeenOpened, float Inflation)
|
||||
{
|
||||
IsPresent = present;
|
||||
this.EventId = EventId;
|
||||
this.CycleId = CycleId;
|
||||
this.RedirectId = RedirectId;
|
||||
this.AmmoCount = AmmoCount;
|
||||
this.CurrentStageIdx = CurrentStageIdx;
|
||||
this.ChainProgress = ChainProgress;
|
||||
this.RewardFlag = RewardFlag;
|
||||
this.TargetFlag = TargetFlag;
|
||||
this.HasBeenOpened = HasBeenOpened;
|
||||
this.Inflation = Inflation;
|
||||
}
|
||||
internal static PlayerKVShootingRangeState Missing() => new PlayerKVShootingRangeState(false, 0, 0, 0, 0, 0, 0, 0, 0, false, 0);
|
||||
}
|
||||
|
||||
|
||||
internal sealed class PlayerKVBuffValue
|
||||
{
|
||||
internal int SortID { get; }
|
||||
internal int sourceID { get; }
|
||||
internal int buffID { get; }
|
||||
internal bool isEnd { get; }
|
||||
internal DateTime buffEndTime { get; }
|
||||
internal PlayerKVBuffValue(int SortID,int sourceID,int buffID,bool isEnd,DateTime buffEndTime)
|
||||
{ this.SortID=SortID; this.sourceID=sourceID; this.buffID=buffID; this.isEnd=isEnd; this.buffEndTime=buffEndTime; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVTriggerValue
|
||||
{
|
||||
internal int ID { get; }
|
||||
internal int index { get; }
|
||||
internal int lastID { get; }
|
||||
internal int vipLevel { get; }
|
||||
internal int mapID { get; }
|
||||
internal string time { get; }
|
||||
internal PlayerKVTriggerValue(int ID,int index,int lastID,int vipLevel,int mapID,string time)
|
||||
{ this.ID=ID; this.index=index; this.lastID=lastID; this.vipLevel=vipLevel; this.mapID=mapID; this.time=time; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVGiftVipValue
|
||||
{
|
||||
internal int oldVipLevel { get; }
|
||||
internal int curVipLevel { get; }
|
||||
internal int timer { get; }
|
||||
internal PlayerKVGiftVipValue(int oldVipLevel,int curVipLevel,int timer)
|
||||
{ this.oldVipLevel=oldVipLevel; this.curVipLevel=curVipLevel; this.timer=timer; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVFixedTimeValue
|
||||
{
|
||||
internal int id { get; }
|
||||
internal string endTime { get; }
|
||||
internal PlayerKVFixedTimeValue(int id,string endTime)
|
||||
{ this.id=id; this.endTime=endTime; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVBuffState
|
||||
{
|
||||
internal IReadOnlyDictionary<int,PlayerKVBuffValue> Map { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVBuffValue> Global { get; }
|
||||
internal IReadOnlyList<PlayerKVBuffValue> Target { get; }
|
||||
internal PlayerKVBuffValue Daily { get; }
|
||||
internal PlayerKVBuffState(IDictionary<int,PlayerKVBuffValue> map,IDictionary<int,PlayerKVBuffValue> global,
|
||||
IEnumerable<PlayerKVBuffValue> target,PlayerKVBuffValue daily)
|
||||
{
|
||||
Map=new ReadOnlyDictionary<int,PlayerKVBuffValue>(new Dictionary<int,PlayerKVBuffValue>(map));
|
||||
Global=new ReadOnlyDictionary<int,PlayerKVBuffValue>(new Dictionary<int,PlayerKVBuffValue>(global));
|
||||
Target=new List<PlayerKVBuffValue>(target).AsReadOnly(); Daily=daily;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVTriggerState
|
||||
{
|
||||
internal IReadOnlyList<PlayerKVTriggerValue> Packs { get; }
|
||||
internal IReadOnlyDictionary<string,PlayerKVGiftVipValue> Gifts { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVFixedTimeValue> Fixed { get; }
|
||||
internal PlayerKVTriggerState(IEnumerable<PlayerKVTriggerValue> packs,IDictionary<string,PlayerKVGiftVipValue> gifts,
|
||||
IDictionary<int,PlayerKVFixedTimeValue> fixedValues)
|
||||
{
|
||||
Packs=new List<PlayerKVTriggerValue>(packs).AsReadOnly();
|
||||
Gifts=new ReadOnlyDictionary<string,PlayerKVGiftVipValue>(new Dictionary<string,PlayerKVGiftVipValue>(gifts,StringComparer.Ordinal));
|
||||
Fixed=new ReadOnlyDictionary<int,PlayerKVFixedTimeValue>(new Dictionary<int,PlayerKVFixedTimeValue>(fixedValues));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal sealed class PlayerKVPlayerState
|
||||
{
|
||||
internal PlayerKVCoreState Core { get; }
|
||||
internal IReadOnlyDictionary<string,string> Modern { get; }
|
||||
internal IReadOnlyDictionary<int,int> VipScores { get; }
|
||||
internal IReadOnlyList<int> NewRods { get; }
|
||||
internal int Season { get; }
|
||||
internal bool Appraise { get; }
|
||||
internal string Building { get; }
|
||||
internal PlayerKVPlayerState(PlayerKVCoreState core,IDictionary<string,string> modern,IDictionary<int,int> scores,
|
||||
IEnumerable<int> rods,int season,bool appraise,string building)
|
||||
{
|
||||
Core=core; Modern=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(modern,StringComparer.Ordinal));
|
||||
VipScores=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(scores)); NewRods=new List<int>(rods).AsReadOnly();
|
||||
Season=season; Appraise=appraise; Building=building;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVPotionItem
|
||||
{
|
||||
internal string Name { get; }
|
||||
internal int Index { get; }
|
||||
internal bool IsOver { get; }
|
||||
internal int DataType { get; }
|
||||
internal IReadOnlyList<int> Colors { get; }
|
||||
internal IReadOnlyList<int> Shelves { get; }
|
||||
internal IReadOnlyList<int> Grids { get; }
|
||||
internal IReadOnlyList<int> FromDisplay { get; }
|
||||
internal PlayerKVPotionItem(string name,int index,bool over,int type,IEnumerable<int> colors,
|
||||
IEnumerable<int> shelves,IEnumerable<int> grids,IEnumerable<int> fromDisplay)
|
||||
{
|
||||
Name=name; Index=index; IsOver=over; DataType=type;
|
||||
Colors=new List<int>(colors).AsReadOnly(); Shelves=new List<int>(shelves).AsReadOnly();
|
||||
Grids=new List<int>(grids).AsReadOnly(); FromDisplay=new List<int>(fromDisplay).AsReadOnly();
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVPotionState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventID { get; }
|
||||
internal int PotionID { get; }
|
||||
internal int StageID { get; }
|
||||
internal int Stage1TotalLevel { get; }
|
||||
internal int Stage2TotalLevel { get; }
|
||||
internal int LevelID { get; }
|
||||
internal int LevelIndex { get; }
|
||||
internal int CorrectNum { get; }
|
||||
internal int SubmitNum { get; }
|
||||
internal int Token { get; }
|
||||
internal int ConsumedToken { get; }
|
||||
internal int Rule { get; }
|
||||
internal int PreEventID { get; }
|
||||
internal int PrePotionID { get; }
|
||||
internal int PreStageID { get; }
|
||||
internal int PreLevelID { get; }
|
||||
internal int Round { get; }
|
||||
internal int FirstPlayedEventId { get; }
|
||||
internal bool IsOver { get; }
|
||||
internal string Name { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVPotionItem LevelTargetData { get; }
|
||||
internal PlayerKVPotionItem LevelDisplayData { get; }
|
||||
internal IReadOnlyList<PlayerKVPotionItem> LevelProcessedList { get; }
|
||||
internal PlayerKVPotionState(bool present,int EventID,int PotionID,int StageID,int Stage1TotalLevel,int Stage2TotalLevel,int LevelID,int LevelIndex,int CorrectNum,int SubmitNum,int Token,int ConsumedToken,int Rule,int PreEventID,int PrePotionID,int PreStageID,int PreLevelID,int Round,int FirstPlayedEventId,bool IsOver,string Name,float InflationRate,PlayerKVPotionItem LevelTargetData,PlayerKVPotionItem LevelDisplayData,List<PlayerKVPotionItem> LevelProcessedList)
|
||||
{
|
||||
IsPresent=present;
|
||||
this.EventID=EventID;
|
||||
this.PotionID=PotionID;
|
||||
this.StageID=StageID;
|
||||
this.Stage1TotalLevel=Stage1TotalLevel;
|
||||
this.Stage2TotalLevel=Stage2TotalLevel;
|
||||
this.LevelID=LevelID;
|
||||
this.LevelIndex=LevelIndex;
|
||||
this.CorrectNum=CorrectNum;
|
||||
this.SubmitNum=SubmitNum;
|
||||
this.Token=Token;
|
||||
this.ConsumedToken=ConsumedToken;
|
||||
this.Rule=Rule;
|
||||
this.PreEventID=PreEventID;
|
||||
this.PrePotionID=PrePotionID;
|
||||
this.PreStageID=PreStageID;
|
||||
this.PreLevelID=PreLevelID;
|
||||
this.Round=Round;
|
||||
this.FirstPlayedEventId=FirstPlayedEventId;
|
||||
this.IsOver=IsOver;
|
||||
this.Name=Name;
|
||||
this.InflationRate=InflationRate;
|
||||
this.LevelTargetData=LevelTargetData;
|
||||
this.LevelDisplayData=LevelDisplayData;
|
||||
this.LevelProcessedList=new List<PlayerKVPotionItem>(LevelProcessedList).AsReadOnly();
|
||||
}
|
||||
internal static PlayerKVPotionState Missing() => new PlayerKVPotionState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List<PlayerKVPotionItem>());
|
||||
}
|
||||
internal sealed class PlayerKVPassRecord
|
||||
{
|
||||
internal int ID { get; }
|
||||
internal int Level { get; }
|
||||
internal int Exp { get; }
|
||||
internal int BuyVipToastIndex { get; }
|
||||
internal int IsUnlockVip { get; }
|
||||
internal IReadOnlyList<int> Normal { get; }
|
||||
internal IReadOnlyList<int> Vip { get; }
|
||||
internal IReadOnlyList<int> LastDrops { get; }
|
||||
internal float Inflation { get; }
|
||||
internal PlayerKVPassRecord(int id,int level,int exp,int toast,int unlock,IEnumerable<int> normal,
|
||||
IEnumerable<int> vip,IEnumerable<int> lastDrops,float inflation)
|
||||
{
|
||||
ID=id; Level=level; Exp=exp; BuyVipToastIndex=toast; IsUnlockVip=unlock;
|
||||
Normal=new List<int>(normal).AsReadOnly(); Vip=new List<int>(vip).AsReadOnly();
|
||||
LastDrops=new List<int>(lastDrops).AsReadOnly(); Inflation=inflation;
|
||||
}
|
||||
internal bool CommonEquals(PlayerKVPassRecord other) => ID==other.ID && Level==other.Level && Exp==other.Exp &&
|
||||
BuyVipToastIndex==other.BuyVipToastIndex && IsUnlockVip==other.IsUnlockVip && Normal.SequenceEqual(other.Normal) && Vip.SequenceEqual(other.Vip);
|
||||
internal static PlayerKVPassRecord Empty() => new PlayerKVPassRecord(0,1,0,0,0,Array.Empty<int>(),Array.Empty<int>(),Array.Empty<int>(),0);
|
||||
}
|
||||
internal sealed class PlayerKVPassTask
|
||||
{
|
||||
internal IReadOnlyDictionary<int,int> Daily { get; }
|
||||
internal IReadOnlyDictionary<int,int> Weekly { get; }
|
||||
internal IReadOnlyList<int> LoginDays { get; }
|
||||
internal DateTime DailyEnd { get; }
|
||||
internal DateTime WeeklyEnd { get; }
|
||||
internal PlayerKVPassTask(IDictionary<int,int> daily,IDictionary<int,int> weekly,IEnumerable<int> days,DateTime dailyEnd,DateTime weeklyEnd)
|
||||
{
|
||||
Daily=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(daily)); Weekly=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(weekly));
|
||||
LoginDays=new List<int>(days).AsReadOnly(); DailyEnd=dailyEnd; WeeklyEnd=weeklyEnd;
|
||||
}
|
||||
internal bool Same(PlayerKVPassTask other) => Daily.Count==other.Daily.Count && Weekly.Count==other.Weekly.Count &&
|
||||
Daily.All(p=>other.Daily.TryGetValue(p.Key,out var value) && value==p.Value) &&
|
||||
Weekly.All(p=>other.Weekly.TryGetValue(p.Key,out var value) && value==p.Value) &&
|
||||
LoginDays.SequenceEqual(other.LoginDays) && DailyEnd==other.DailyEnd && WeeklyEnd==other.WeeklyEnd;
|
||||
internal static PlayerKVPassTask Empty() => new PlayerKVPassTask(new Dictionary<int,int>(),new Dictionary<int,int>(),Array.Empty<int>(),default,default);
|
||||
}
|
||||
internal sealed class PlayerKVBattlePassState
|
||||
{
|
||||
internal PlayerKVPassRecord Record { get; }
|
||||
internal PlayerKVPassTask Task { get; }
|
||||
internal PlayerKVBattlePassState(PlayerKVPassRecord record,PlayerKVPassTask task) { Record=record; Task=task; }
|
||||
}
|
||||
internal sealed class PlayerKVCollectValue
|
||||
{
|
||||
internal int ItemId { get; }
|
||||
internal int Count { get; }
|
||||
internal int MaxCount { get; }
|
||||
internal int ItemTrueId { get; }
|
||||
internal PlayerKVCollectValue(int itemId,int count,int maxCount,int trueId)
|
||||
{ ItemId=itemId; Count=count; MaxCount=maxCount; ItemTrueId=trueId; }
|
||||
}
|
||||
internal sealed class PlayerKVCubeState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int TicketCount { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal bool IsFinished { get; }
|
||||
internal int EventId { get; }
|
||||
internal bool IsInGuide { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal int CurrentStagetId { get; }
|
||||
internal IReadOnlyList<PlayerKVCollectValue> CollectItems { get; }
|
||||
internal IReadOnlyList<int> BoardData { get; }
|
||||
internal int Width { get; }
|
||||
internal int Height { get; }
|
||||
internal IReadOnlyList<int> CollectRewardsId { get; }
|
||||
internal bool IsFinalRewardGet { get; }
|
||||
internal IReadOnlyList<IReadOnlyList<int>> PieceDatas { get; }
|
||||
internal int Cycle { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVCubeState(bool present,int ticketCount,bool firstOpen,bool finished,int eventId,bool guide,
|
||||
DateTime start,DateTime end,int stage,IEnumerable<PlayerKVCollectValue> items,IEnumerable<int> board,
|
||||
int width,int height,IEnumerable<int> rewards,bool final,IEnumerable<IEnumerable<int>> pieces,int cycle,float inflation)
|
||||
{
|
||||
IsPresent=present; TicketCount=ticketCount; FirstOpen=firstOpen; IsFinished=finished; EventId=eventId; IsInGuide=guide;
|
||||
StartTime=start; EndTime=end; CurrentStagetId=stage; CollectItems=new List<PlayerKVCollectValue>(items).AsReadOnly();
|
||||
BoardData=new List<int>(board).AsReadOnly(); Width=width; Height=height; CollectRewardsId=new List<int>(rewards).AsReadOnly();
|
||||
IsFinalRewardGet=final; PieceDatas=pieces.Select(p=>(IReadOnlyList<int>)new List<int>(p).AsReadOnly()).ToList().AsReadOnly();
|
||||
Cycle=cycle; InflationRate=inflation;
|
||||
}
|
||||
internal static PlayerKVCubeState Missing() => new PlayerKVCubeState(false,0,false,false,0,false,default,default,0,
|
||||
Array.Empty<PlayerKVCollectValue>(),Array.Empty<int>(),0,0,Array.Empty<int>(),false,Array.Empty<IEnumerable<int>>(),0,0);
|
||||
}
|
||||
internal sealed class PlayerKVPinataState
|
||||
{
|
||||
internal int EventID { get; }
|
||||
internal int PinataMainID { get; }
|
||||
internal int PurchasedCount { get; }
|
||||
internal PlayerKVPinataState(int eventId,int mainId,int count) { EventID=eventId; PinataMainID=mainId; PurchasedCount=count; }
|
||||
}
|
||||
internal sealed class PlayerKVEmptyModuleState
|
||||
{
|
||||
internal static readonly PlayerKVEmptyModuleState Instance=new PlayerKVEmptyModuleState();
|
||||
private PlayerKVEmptyModuleState() { }
|
||||
}
|
||||
internal sealed class PlayerKVStampValue
|
||||
{
|
||||
internal int EventScratchCardID { get; }
|
||||
internal int Count { get; }
|
||||
internal PlayerKVStampValue(int id,int count) { EventScratchCardID=id; Count=count; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVChallengeState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
/// <summary>Internal in-memory representation of a present JSON null; not serialized to the server.</summary>
|
||||
internal bool IsExplicitNull { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal int EventId { get; }
|
||||
internal int RankTierLevel { get; }
|
||||
internal int Finished { get; }
|
||||
internal int RedirectID { get; }
|
||||
internal int Score { get; }
|
||||
internal int ProcessedScore { get; }
|
||||
internal int step { get; }
|
||||
internal int uiStep { get; }
|
||||
internal int index { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal IReadOnlyList<int> eliminateNumber { get; }
|
||||
internal IReadOnlyList<int> robotAccountList { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVChallengeState(bool present,bool FirstOpen,int EventId,int RankTierLevel,int Finished,int RedirectID,int Score,int ProcessedScore,int step,int uiStep,int index,DateTime StartTime,DateTime EndTime,List<int> eliminateNumber,List<int> robotAccountList,float InflationRate,bool explicitNull=false)
|
||||
{
|
||||
if (explicitNull && !present) throw new ArgumentException("An explicit null must have a present key.", nameof(explicitNull));
|
||||
IsPresent=present;
|
||||
IsExplicitNull=explicitNull;
|
||||
this.FirstOpen=FirstOpen;
|
||||
this.EventId=EventId;
|
||||
this.RankTierLevel=RankTierLevel;
|
||||
this.Finished=Finished;
|
||||
this.RedirectID=RedirectID;
|
||||
this.Score=Score;
|
||||
this.ProcessedScore=ProcessedScore;
|
||||
this.step=step;
|
||||
this.uiStep=uiStep;
|
||||
this.index=index;
|
||||
this.StartTime=StartTime;
|
||||
this.EndTime=EndTime;
|
||||
this.eliminateNumber=new List<int>(eliminateNumber).AsReadOnly();
|
||||
this.robotAccountList=new List<int>(robotAccountList).AsReadOnly();
|
||||
this.InflationRate=InflationRate;
|
||||
}
|
||||
internal static PlayerKVChallengeState Missing() => new PlayerKVChallengeState(false,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new List<int>(),default);
|
||||
/// <summary>Creates an internal empty-state marker, not a server serialization payload.</summary>
|
||||
internal static PlayerKVChallengeState ExplicitNull() => new PlayerKVChallengeState(true,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new List<int>(),default,true);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVPinballState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventID { get; }
|
||||
internal int PinballMainID { get; }
|
||||
internal int SpinPoint { get; }
|
||||
internal int Scores { get; }
|
||||
internal int Multiple { get; }
|
||||
internal int Token { get; }
|
||||
internal int ConsumedToken { get; }
|
||||
internal int RewardIndex { get; }
|
||||
internal float Force { get; }
|
||||
internal float LuckyValue { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVPinballState(bool present,int EventID,int PinballMainID,int SpinPoint,int Scores,int Multiple,int Token,int ConsumedToken,int RewardIndex,float Force,float LuckyValue,float InflationRate)
|
||||
{
|
||||
IsPresent=present;
|
||||
this.EventID=EventID;
|
||||
this.PinballMainID=PinballMainID;
|
||||
this.SpinPoint=SpinPoint;
|
||||
this.Scores=Scores;
|
||||
this.Multiple=Multiple;
|
||||
this.Token=Token;
|
||||
this.ConsumedToken=ConsumedToken;
|
||||
this.RewardIndex=RewardIndex;
|
||||
this.Force=Force;
|
||||
this.LuckyValue=LuckyValue;
|
||||
this.InflationRate=InflationRate;
|
||||
}
|
||||
internal static PlayerKVPinballState Missing() => new PlayerKVPinballState(false,default,default,default,default,default,default,default,default,default,default,default);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVScratchState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventID { get; }
|
||||
internal int ScratchMainID { get; }
|
||||
internal int ChainPackID { get; }
|
||||
internal int Token { get; }
|
||||
internal int ConsumedToken { get; }
|
||||
internal int RewardTaskConsumedToken { get; }
|
||||
internal int EventScratchRewardTaskID { get; }
|
||||
internal int EventScratchCardID { get; }
|
||||
internal int TotalConsumedToken { get; }
|
||||
internal int ChainPackProgress { get; }
|
||||
internal int magIndex { get; }
|
||||
internal bool IsOver { get; }
|
||||
internal float LuckyValue { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal IReadOnlyList<int> StampRewardList { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVStampValue> StampRewardDict { get; }
|
||||
internal PlayerKVScratchState(bool present,int EventID,int ScratchMainID,int ChainPackID,int Token,int ConsumedToken,int RewardTaskConsumedToken,int EventScratchRewardTaskID,int EventScratchCardID,int TotalConsumedToken,int ChainPackProgress,int magIndex,bool IsOver,float LuckyValue,float InflationRate,List<int> StampRewardList,Dictionary<int,PlayerKVStampValue> StampRewardDict)
|
||||
{
|
||||
IsPresent=present;
|
||||
this.EventID=EventID;
|
||||
this.ScratchMainID=ScratchMainID;
|
||||
this.ChainPackID=ChainPackID;
|
||||
this.Token=Token;
|
||||
this.ConsumedToken=ConsumedToken;
|
||||
this.RewardTaskConsumedToken=RewardTaskConsumedToken;
|
||||
this.EventScratchRewardTaskID=EventScratchRewardTaskID;
|
||||
this.EventScratchCardID=EventScratchCardID;
|
||||
this.TotalConsumedToken=TotalConsumedToken;
|
||||
this.ChainPackProgress=ChainPackProgress;
|
||||
this.magIndex=magIndex;
|
||||
this.IsOver=IsOver;
|
||||
this.LuckyValue=LuckyValue;
|
||||
this.InflationRate=InflationRate;
|
||||
this.StampRewardList=new List<int>(StampRewardList).AsReadOnly();
|
||||
this.StampRewardDict=new ReadOnlyDictionary<int,PlayerKVStampValue>(new Dictionary<int,PlayerKVStampValue>(StampRewardDict));
|
||||
}
|
||||
internal static PlayerKVScratchState Missing() => new PlayerKVScratchState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new Dictionary<int,PlayerKVStampValue>());
|
||||
}
|
||||
internal sealed class PlayerKVOfferItem
|
||||
{
|
||||
internal int Id { get; }
|
||||
internal int Item { get; }
|
||||
internal int Number { get; }
|
||||
internal int Status { get; }
|
||||
internal PlayerKVOfferItem(int id,int item,int number,int status) { Id=id; Item=item; Number=number; Status=status; }
|
||||
}
|
||||
internal sealed class PlayerKVOfferState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal int EventId { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal int ItemId { get; }
|
||||
internal int LotteryNum { get; }
|
||||
internal IReadOnlyList<PlayerKVOfferItem> Items { get; }
|
||||
internal IReadOnlyList<int> RewardList { get; }
|
||||
internal bool Finished { get; }
|
||||
internal int ChestReward { get; }
|
||||
internal PlayerKVOfferState(bool present,bool firstOpen,int eventId,DateTime startTime,DateTime endTime,
|
||||
int itemId,int lotteryNum,IEnumerable<PlayerKVOfferItem> items,IEnumerable<int> rewardList,bool finished,int chestReward)
|
||||
{
|
||||
IsPresent=present; FirstOpen=firstOpen; EventId=eventId; StartTime=startTime; EndTime=endTime;
|
||||
ItemId=itemId; LotteryNum=lotteryNum; Items=new List<PlayerKVOfferItem>(items).AsReadOnly();
|
||||
RewardList=new List<int>(rewardList).AsReadOnly(); Finished=finished; ChestReward=chestReward;
|
||||
}
|
||||
internal static PlayerKVOfferState Missing() => new PlayerKVOfferState(false,false,0,default,default,0,0,
|
||||
Array.Empty<PlayerKVOfferItem>(),Array.Empty<int>(),false,0);
|
||||
}
|
||||
internal sealed class PlayerKVEventConfig
|
||||
{
|
||||
internal int RedirectId { get; }
|
||||
internal string EndTime { get; }
|
||||
internal PlayerKVEventConfig(int redirectId,string endTime) { RedirectId=redirectId; EndTime=endTime; }
|
||||
}
|
||||
internal sealed class PlayerKVEventPackState
|
||||
{
|
||||
internal int EventId { get; }
|
||||
internal int PurchaseCount { get; }
|
||||
internal int VipLevel { get; }
|
||||
internal int RedirectId { get; }
|
||||
internal PlayerKVEventPackState(int eventId,int purchaseCount,int vipLevel,int redirectId)
|
||||
{ EventId=eventId; PurchaseCount=purchaseCount; VipLevel=vipLevel; RedirectId=redirectId; }
|
||||
}
|
||||
internal sealed class PlayerKVCampState
|
||||
{
|
||||
internal bool IsFix { get; }
|
||||
internal string Skyscraper { get; }
|
||||
internal IReadOnlyDictionary<string,int> Values { get; }
|
||||
internal PlayerKVCampState(bool isFix,string skyscraper,IDictionary<string,int> values)
|
||||
{ IsFix=isFix; Skyscraper=skyscraper; Values=new ReadOnlyDictionary<string,int>(new Dictionary<string,int>(values,StringComparer.Ordinal)); }
|
||||
}
|
||||
internal sealed class PlayerKVClubState
|
||||
{
|
||||
internal DateTime? SignTime { get; }
|
||||
internal DateTime DonateTime { get; }
|
||||
internal int DonateCount { get; }
|
||||
internal DateTime RequestEnd { get; }
|
||||
internal bool OpenRed { get; }
|
||||
internal PlayerKVClubState(DateTime? signTime, DateTime donateTime, int donateCount, DateTime requestEnd, bool openRed)
|
||||
{ SignTime=signTime; DonateTime=donateTime; DonateCount=donateCount; RequestEnd=requestEnd; OpenRed=openRed; }
|
||||
}
|
||||
internal sealed class PlayerKVProgressPackState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventId { get; }
|
||||
internal int RedirectId { get; }
|
||||
internal int TokenProgress { get; }
|
||||
internal int ChainProgress { get; }
|
||||
internal PlayerKVProgressPackState(bool present, int eventId, int redirectId, int tokenProgress, int chainProgress)
|
||||
{ IsPresent=present; EventId=eventId; RedirectId=redirectId; TokenProgress=tokenProgress; ChainProgress=chainProgress; }
|
||||
}
|
||||
internal sealed class PlayerKVIntMapState
|
||||
{
|
||||
internal IReadOnlyDictionary<int, int> Values { get; }
|
||||
internal PlayerKVIntMapState(IDictionary<int, int> values)
|
||||
{ Values = new ReadOnlyDictionary<int, int>(new Dictionary<int, int>(values)); }
|
||||
}
|
||||
internal sealed class PlayerKVGuideValue
|
||||
{
|
||||
internal int Index { get; }
|
||||
internal bool Finished { get; }
|
||||
internal string Group { get; }
|
||||
internal PlayerKVGuideValue(int index, bool finished, string group)
|
||||
{ Index = index; Finished = finished; Group = group; }
|
||||
internal bool Same(PlayerKVGuideValue other) => Index == other.Index && Finished == other.Finished && Group == other.Group;
|
||||
}
|
||||
internal sealed class PlayerKVGuideState
|
||||
{
|
||||
internal IReadOnlyDictionary<string, PlayerKVGuideValue> Values { get; }
|
||||
internal PlayerKVGuideState(IDictionary<string, PlayerKVGuideValue> values)
|
||||
{ Values = new ReadOnlyDictionary<string, PlayerKVGuideValue>(new Dictionary<string, PlayerKVGuideValue>(values, StringComparer.Ordinal)); }
|
||||
}
|
||||
internal sealed class PlayerKVMonthlySignState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int Index { get; }
|
||||
internal int DailyId { get; }
|
||||
internal int DailyIndex { get; }
|
||||
internal int LastId { get; }
|
||||
internal string Time { get; }
|
||||
internal PlayerKVMonthlySignState(bool present, int index, int dailyId, int dailyIndex, int lastId, string time)
|
||||
{ IsPresent = present; Index = index; DailyId = dailyId; DailyIndex = dailyIndex; LastId = lastId; Time = time; }
|
||||
}
|
||||
internal sealed class PlayerKVDailyValue
|
||||
{
|
||||
internal int Id { get; } internal int Index { get; } internal int LastId { get; } internal string Time { get; }
|
||||
internal PlayerKVDailyValue(int id,int index,int lastId,string time) { Id=id; Index=index; LastId=lastId; Time=time; }
|
||||
}
|
||||
internal sealed class PlayerKVAdvertValue
|
||||
{
|
||||
internal int Id { get; } internal int Index { get; } internal int Day { get; }
|
||||
internal string Time { get; } internal bool Open { get; }
|
||||
internal PlayerKVAdvertValue(int id,int index,int day,string time,bool open) { Id=id; Index=index; Day=day; Time=time; Open=open; }
|
||||
}
|
||||
internal sealed class PlayerKVShopItemValue
|
||||
{
|
||||
internal int Id { get; } internal float Discount { get; } internal int Bought { get; }
|
||||
internal PlayerKVShopItemValue(int id,float discount,int bought) { Id=id; Discount=discount; Bought=bought; }
|
||||
}
|
||||
internal sealed class PlayerKVCouponState
|
||||
{
|
||||
internal DateTime ActivateTime { get; } internal int EventId { get; } internal int RedirectId { get; } internal bool Bought { get; }
|
||||
internal PlayerKVCouponState(DateTime time,int eventId,int redirectId,bool bought) { ActivateTime=time; EventId=eventId; RedirectId=redirectId; Bought=bought; }
|
||||
}
|
||||
internal sealed class PlayerKVShopState
|
||||
{
|
||||
internal IReadOnlyDictionary<string,int> Daily { get; }
|
||||
internal IReadOnlyDictionary<int,int> All { get; } internal IReadOnlyDictionary<int,int> Gift { get; }
|
||||
internal IReadOnlyDictionary<int,int> Advert { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVShopItemValue> Items { get; }
|
||||
internal IReadOnlyList<float> Buy { get; }
|
||||
internal int Voucher { get; } internal int RefreshCount { get; } internal DateTime RefreshTime { get; }
|
||||
internal PlayerKVDailyValue Chain { get; } internal PlayerKVDailyValue Turntable { get; }
|
||||
internal PlayerKVAdvertValue Vip { get; } internal PlayerKVCouponState Coupon { get; }
|
||||
internal PlayerKVShopState(IDictionary<string,int> daily,IDictionary<int,int> all,IDictionary<int,int> gift,
|
||||
IDictionary<int,int> advert,IDictionary<int,PlayerKVShopItemValue> items,IEnumerable<float> buy,int voucher,int refreshCount,
|
||||
DateTime refreshTime,PlayerKVDailyValue chain,PlayerKVDailyValue turntable,PlayerKVAdvertValue vip,PlayerKVCouponState coupon)
|
||||
{
|
||||
Daily=new ReadOnlyDictionary<string,int>(new Dictionary<string,int>(daily,StringComparer.Ordinal));
|
||||
All=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(all)); Gift=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(gift));
|
||||
Advert=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(advert));
|
||||
Items=new ReadOnlyDictionary<int,PlayerKVShopItemValue>(new Dictionary<int,PlayerKVShopItemValue>(items));
|
||||
Buy=new List<float>(buy).AsReadOnly(); Voucher=voucher; RefreshCount=refreshCount; RefreshTime=refreshTime;
|
||||
Chain=chain; Turntable=turntable; Vip=vip; Coupon=coupon;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVLimitedValue
|
||||
{
|
||||
internal int Id { get; } internal double Duration { get; } internal string StartTime { get; }
|
||||
internal PlayerKVLimitedValue(int id,double duration,string time) { Id=id; Duration=duration; StartTime=time; }
|
||||
}
|
||||
internal sealed class PlayerKVEventBaseState
|
||||
{
|
||||
internal IReadOnlyDictionary<int,PlayerKVLimitedValue> Limited { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVFixedTimeValue> Fixed { get; }
|
||||
internal IReadOnlyDictionary<int,int> Target { get; } internal IReadOnlyDictionary<int,int> Rank { get; }
|
||||
internal IReadOnlyDictionary<int,int> Wheel { get; }
|
||||
internal int Coupon { get; } internal int Operate { get; } internal int AllOperate { get; }
|
||||
internal int Free { get; } internal int PvpToken { get; }
|
||||
internal PlayerKVEventBaseState(IDictionary<int,PlayerKVLimitedValue> limited,IDictionary<int,PlayerKVFixedTimeValue> fixedValues,
|
||||
IDictionary<int,int> target,IDictionary<int,int> rank,IDictionary<int,int> wheel,int coupon,int operate,int allOperate,int free,int pvpToken)
|
||||
{
|
||||
Limited=new ReadOnlyDictionary<int,PlayerKVLimitedValue>(new Dictionary<int,PlayerKVLimitedValue>(limited));
|
||||
Fixed=new ReadOnlyDictionary<int,PlayerKVFixedTimeValue>(new Dictionary<int,PlayerKVFixedTimeValue>(fixedValues));
|
||||
Target=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(target)); Rank=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(rank));
|
||||
Wheel=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(wheel)); Coupon=coupon; Operate=operate; AllOperate=allOperate; Free=free; PvpToken=pvpToken;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVFishConfig
|
||||
{
|
||||
internal int Id { get; } internal int Area { get; } internal int Quality { get; } internal int Card { get; }
|
||||
internal float Weight { get; } internal float Extra { get; } internal float Random { get; }
|
||||
internal PlayerKVFishConfig(int id,int area,int quality,int card,float weight,float extra,float random)
|
||||
{ Id=id; Area=area; Quality=quality; Card=card; Weight=weight; Extra=extra; Random=random; }
|
||||
}
|
||||
internal sealed class PlayerKVFishCardConfig
|
||||
{
|
||||
internal int Id { get; } internal int MaxLevel { get; }
|
||||
internal IReadOnlyList<int> Required { get; } internal IReadOnlyList<float> Weights { get; }
|
||||
internal PlayerKVFishCardConfig(int id,int max,IEnumerable<int> required,IEnumerable<float> weights)
|
||||
{ Id=id; MaxLevel=max; Required=new List<int>(required).AsReadOnly(); Weights=new List<float>(weights).AsReadOnly(); }
|
||||
}
|
||||
internal sealed class PlayerKVFishValue
|
||||
{
|
||||
internal int Level { get; } internal float Weight { get; } internal int Cards { get; }
|
||||
internal int Number { get; } internal bool New { get; }
|
||||
internal IReadOnlyList<string> Compatibility { get; }
|
||||
internal PlayerKVFishValue(int level,float weight,int cards,int number,bool isNew,IEnumerable<string> compatibility=null)
|
||||
{ Level=level; Weight=weight; Cards=cards; Number=number; New=isNew; Compatibility=new List<string>(compatibility ?? Array.Empty<string>()).AsReadOnly(); }
|
||||
internal bool Same(PlayerKVFishValue other) => Level==other.Level && Weight==other.Weight && Cards==other.Cards && Number==other.Number && New==other.New;
|
||||
}
|
||||
internal sealed class PlayerKVRodAccessories
|
||||
{
|
||||
internal int Glove { get; } internal IReadOnlyDictionary<int,int> RodSkin { get; }
|
||||
internal IReadOnlyList<int> Gloves { get; } internal IReadOnlyList<int> Skins { get; }
|
||||
internal IReadOnlyList<int> NewGloves { get; } internal IReadOnlyList<int> NewSkins { get; }
|
||||
internal PlayerKVRodAccessories(int glove,IDictionary<int,int> rodSkin,IEnumerable<int> gloves,IEnumerable<int> skins,IEnumerable<int> newGloves,IEnumerable<int> newSkins)
|
||||
{ Glove=glove; RodSkin=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(rodSkin)); Gloves=new List<int>(gloves).AsReadOnly();
|
||||
Skins=new List<int>(skins).AsReadOnly(); NewGloves=new List<int>(newGloves).AsReadOnly(); NewSkins=new List<int>(newSkins).AsReadOnly(); }
|
||||
}
|
||||
internal sealed class PlayerKVFishState
|
||||
{
|
||||
internal IReadOnlyDictionary<int,int> Rods { get; } internal IReadOnlyDictionary<int,int> RodLevels { get; }
|
||||
internal IReadOnlyDictionary<int,int> Swim { get; } internal IReadOnlyDictionary<int,int> SwimCount { get; }
|
||||
internal IReadOnlyDictionary<int,int> Progress { get; } internal IReadOnlyDictionary<int,int> Honor { get; }
|
||||
internal IReadOnlyDictionary<string,float> Luck { get; } internal PlayerKVRodAccessories Accessories { get; }
|
||||
internal IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> RawMaps { get; }
|
||||
internal IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> Maps { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVFishValue> Fish { get; } internal IReadOnlyDictionary<int,int> Cards { get; }
|
||||
static IReadOnlyDictionary<int,int> Copy(IDictionary<int,int> values) => new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(values));
|
||||
static IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> CopyMaps(IDictionary<int,Dictionary<int,PlayerKVFishValue>> maps) =>
|
||||
new ReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>>(maps.ToDictionary(p=>p.Key,p=>(IReadOnlyDictionary<int,PlayerKVFishValue>)new ReadOnlyDictionary<int,PlayerKVFishValue>(new Dictionary<int,PlayerKVFishValue>(p.Value))));
|
||||
internal PlayerKVFishState(IDictionary<int,int> rods,IDictionary<int,int> rodLevels,IDictionary<int,int> swim,IDictionary<int,int> swimCount,
|
||||
IDictionary<int,int> progress,IDictionary<int,int> honor,IDictionary<string,float> luck,PlayerKVRodAccessories accessories,
|
||||
IDictionary<int,Dictionary<int,PlayerKVFishValue>> rawMaps,IDictionary<int,Dictionary<int,PlayerKVFishValue>> maps,
|
||||
IDictionary<int,PlayerKVFishValue> fish,IDictionary<int,int> cards)
|
||||
{ Rods=Copy(rods); RodLevels=Copy(rodLevels); Swim=Copy(swim); SwimCount=Copy(swimCount); Progress=Copy(progress); Honor=Copy(honor);
|
||||
Luck=new ReadOnlyDictionary<string,float>(new Dictionary<string,float>(luck,StringComparer.Ordinal)); Accessories=accessories;
|
||||
RawMaps=CopyMaps(rawMaps); Maps=CopyMaps(maps); Fish=new ReadOnlyDictionary<int,PlayerKVFishValue>(new Dictionary<int,PlayerKVFishValue>(fish)); Cards=Copy(cards); }
|
||||
}
|
||||
internal sealed class PlayerKVFootPrintState
|
||||
{
|
||||
internal ulong Bits { get; }
|
||||
internal PlayerKVFootPrintState(ulong bits) { Bits = bits; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35b5e1a67b2043b5aa3c34a67d2fe8f0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46103e7278684f2db18a8cb6340a4b76
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVGameLoadError
|
||||
{ None, BackendNotEnabled, BackendUnsupported, Input, Prepare, Schema, Apply, Stale, SideEffect, GateBusy, PreviousClosePending, PreviousCloseFailed, ClosePending, CloseFailed }
|
||||
internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation }
|
||||
// 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 => Error==PlayerKVGameLoadError.None ? PlayerKVGameLoadState.LoadedForValidation : PlayerKVGameLoadState.Rejected;
|
||||
internal PlayerKVGameLoadError Error { get; }
|
||||
internal PlayerKVGameLoadError OriginalError { get; }
|
||||
internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed;
|
||||
internal PlayerKVSchemaVerdict? SchemaVerdict { get; }
|
||||
internal PlayerKVPrepareEvidence Prepare { get; }
|
||||
internal long Epoch { get; }
|
||||
internal int ModuleCount { get; }
|
||||
internal int IntentCount { get; }
|
||||
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null,
|
||||
PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0)
|
||||
{ Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents; }
|
||||
}
|
||||
internal sealed class PlayerKVGameLoadCoordinator
|
||||
{
|
||||
readonly SemaphoreSlim loginGate=new SemaphoreSlim(1,1);
|
||||
readonly object closeGate=new object();
|
||||
readonly PlayerKVGameLoadTransaction transaction;
|
||||
readonly Func<bool> unstarted;
|
||||
readonly Action notify;
|
||||
readonly Func<int> intentCount;
|
||||
readonly Action<PlayerKVAccountLease> bind;
|
||||
readonly Func<Task> unbind;
|
||||
readonly PlayerKVShutdownRegistry registry;
|
||||
readonly int mainThread;
|
||||
readonly SynchronizationContext mainContext;
|
||||
PlayerKVLoginContext context;
|
||||
PlayerKVGameLoadGuard guard;
|
||||
PlayerKVGameLoadCapability capability;
|
||||
Task closing;
|
||||
CancellationTokenRegistration lifetimeRegistration;
|
||||
Action clearAfterApply;
|
||||
long epoch;
|
||||
bool loaded;
|
||||
internal bool LoadedForValidation => loaded && context!=null && context.IsCurrent && guard!=null && guard.IsCurrent && guard.Violations==0;
|
||||
internal int QuitObserveMs => capability?.QuitObserveMs ?? 5000;
|
||||
internal PlayerKVGameLoadCoordinator(PlayerKVGameLoadTransaction transaction,Func<bool> unstarted,Action notify,Func<int> intentCount,
|
||||
Action<PlayerKVAccountLease> bind,Func<Task> unbind,PlayerKVShutdownRegistry registry=null)
|
||||
{
|
||||
this.transaction=transaction ?? throw new ArgumentNullException(nameof(transaction));
|
||||
this.unstarted=unstarted ?? throw new ArgumentNullException(nameof(unstarted)); this.notify=notify ?? throw new ArgumentNullException(nameof(notify));
|
||||
this.intentCount=intentCount ?? throw new ArgumentNullException(nameof(intentCount)); this.bind=bind ?? throw new ArgumentNullException(nameof(bind));
|
||||
this.unbind=unbind ?? throw new ArgumentNullException(nameof(unbind)); this.registry=registry ?? PlayerKVShutdownRegistry.Process;
|
||||
mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current;
|
||||
}
|
||||
internal async Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
|
||||
{
|
||||
if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id))
|
||||
return Result(PlayerKVGameLoadError.Input);
|
||||
if(!await EnterGate(next,token)) return Result(token.IsCancellationRequested ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.GateBusy);
|
||||
PlayerKVLoginPrepareResult prepared=null; PlayerKVSchemaVerdict? verdict=null;
|
||||
long attempt=0;
|
||||
try
|
||||
{
|
||||
// An earlier successful validation must also be explicitly revoked before replacing its account.
|
||||
if(context!=null) _=Revoke();
|
||||
var previous=await registry.ObserveAsync(next.LoginObserveMs,token,next.Delay);
|
||||
if(previous!=PlayerKVShutdownStatus.Ready) return Result(previous==PlayerKVShutdownStatus.Failed ? PlayerKVGameLoadError.PreviousCloseFailed :
|
||||
previous==PlayerKVShutdownStatus.Cancelled ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.PreviousClosePending);
|
||||
if(token.IsCancellationRequested || !unstarted() || transaction.Poisoned) return Result(PlayerKVGameLoadError.Input);
|
||||
if(!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard)) return Result(PlayerKVGameLoadError.GateBusy);
|
||||
lifetimeRegistration.Dispose();
|
||||
guard=enteredGuard; capability=next; closing=null; loaded=false;
|
||||
attempt=Interlocked.Increment(ref epoch);
|
||||
context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent()));
|
||||
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,verdict);
|
||||
prepared=await work;
|
||||
}
|
||||
if(!prepared.Prepared) return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare,attempt,next,prepared,verdict);
|
||||
if(!context.IsCurrent || epoch!=attempt) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
var decoded=PlayerKVSaveSchema.Decode(prepared,context.AccountDescriptor,id,attempt,context.IsCurrent,next.Snapshot,next.Snapshot.Version,next.Identity);
|
||||
verdict=decoded.Verdict;
|
||||
if(decoded.Data==null) return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
|
||||
if(!context.TryAcquireAccountLease(next.LeaseCapability,out var lease)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
bind(lease);
|
||||
PlayerKVApplyError applied;
|
||||
try { applied=transaction.Apply(decoded.Data,()=>context.IsCurrent && epoch==attempt && unstarted(),()=>guard.IsCurrent && guard.Violations==0,notify); }
|
||||
finally { var pending=clearAfterApply; clearAfterApply=null; pending?.Invoke(); }
|
||||
if(applied!=PlayerKVApplyError.None) return await Reject(applied==PlayerKVApplyError.Stale ? PlayerKVGameLoadError.Stale :
|
||||
applied==PlayerKVApplyError.SideEffect ? PlayerKVGameLoadError.SideEffect : PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict);
|
||||
if(token.IsCancellationRequested || !context.IsCurrent || guard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
loaded=true;
|
||||
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intentCount());
|
||||
}
|
||||
catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); }
|
||||
finally { loginGate.Release(); }
|
||||
}
|
||||
async Task<bool> EnterGate(PlayerKVGameLoadCapability next,CancellationToken token)
|
||||
{
|
||||
using(var cancellation=CancellationTokenSource.CreateLinkedTokenSource(token))
|
||||
{
|
||||
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;
|
||||
loaded=false; Interlocked.Increment(ref epoch);
|
||||
if(context==null && guard==null) return Task.CompletedTask;
|
||||
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
closing=completion.Task;
|
||||
var owner=context; var leaseGuard=guard; var source=capability;
|
||||
// Context cancellation is synchronous and occurs even if the main-thread reset is queued.
|
||||
var drain=owner?.CloseAsync() ?? Task.CompletedTask;
|
||||
var cleared=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void Clear()
|
||||
{
|
||||
try
|
||||
{
|
||||
var detached=unbind();
|
||||
if(!transaction.Clear()) throw new InvalidOperationException("Controlled container is poisoned.");
|
||||
CompleteClear(detached,cleared);
|
||||
}
|
||||
catch(Exception ex) { cleared.TrySetException(ex); }
|
||||
}
|
||||
if(Thread.CurrentThread.ManagedThreadId==mainThread && transaction.IsApplying) clearAfterApply=Clear;
|
||||
else if(Thread.CurrentThread.ManagedThreadId==mainThread) Clear();
|
||||
else if(mainContext!=null) mainContext.Post(_=>Clear(),null);
|
||||
else cleared.TrySetException(new InvalidOperationException("Main-thread cleanup unavailable."));
|
||||
_=FinishClose(drain,cleared.Task,leaseGuard,source,completion);
|
||||
registry.Register(drain,owner);
|
||||
registry.Register(cleared.Task,owner);
|
||||
registry.Register(closing,owner);
|
||||
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,PlayerKVSchemaVerdict? verdict)
|
||||
{
|
||||
var close=Revoke();
|
||||
var status=await registry.ObserveAsync(source.LoginObserveMs,CancellationToken.None,source.Delay);
|
||||
var error=status==PlayerKVShutdownStatus.Failed || close.IsFaulted ? PlayerKVGameLoadError.CloseFailed :
|
||||
!close.IsCompleted ? PlayerKVGameLoadError.ClosePending : original;
|
||||
return new PlayerKVGameLoadResult(error,attempt,original:prepare?.CleanupFailed==true ? PlayerKVGameLoadError.Prepare : original,verdict:verdict,prepare:prepare);
|
||||
}
|
||||
PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error,Interlocked.Read(ref epoch));
|
||||
internal Task<PlayerKVShutdownStatus> ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs,delay:capability?.Delay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 097cfee9d4554f3fadd160787f0857af
|
||||
@@ -0,0 +1,43 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90067312ead44486b7d1c9694e2a0814
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal interface IPlayerKVLoadModule
|
||||
{
|
||||
int Id { get; }
|
||||
void Reset();
|
||||
void Replace(PlayerKVDecodedSave decoded);
|
||||
}
|
||||
internal sealed class PlayerKVLoadModule : IPlayerKVLoadModule
|
||||
{
|
||||
readonly Action reset;
|
||||
readonly Action<PlayerKVDecodedSave> replace;
|
||||
public int Id { get; }
|
||||
internal PlayerKVLoadModule(int id,Action reset,Action<PlayerKVDecodedSave> replace)
|
||||
{ Id=id; this.reset=reset ?? throw new ArgumentNullException(nameof(reset)); this.replace=replace ?? throw new ArgumentNullException(nameof(replace)); }
|
||||
public void Reset() => reset();
|
||||
public void Replace(PlayerKVDecodedSave decoded) => replace(decoded);
|
||||
}
|
||||
internal enum PlayerKVApplyError { None, Input, Stale, Apply, SideEffect, Poisoned }
|
||||
internal sealed class PlayerKVGameLoadTransaction
|
||||
{
|
||||
readonly IPlayerKVLoadModule[] modules;
|
||||
readonly int mainThread;
|
||||
readonly Action discard;
|
||||
bool applying;
|
||||
internal bool IsApplying => applying;
|
||||
internal bool Poisoned { get; private set; }
|
||||
internal int AppliedModules { get; private set; }
|
||||
internal PlayerKVGameLoadTransaction(IEnumerable<IPlayerKVLoadModule> modules,int mainThread,Action discard)
|
||||
{
|
||||
this.modules=modules?.ToArray() ?? throw new ArgumentNullException(nameof(modules));
|
||||
if(this.modules.Length!=31 || !this.modules.Select(m=>m?.Id ?? 0).SequenceEqual(Enumerable.Range(1,31)))
|
||||
throw new ArgumentException("Exactly 31 ordered modules required.");
|
||||
this.mainThread=mainThread; this.discard=discard ?? throw new ArgumentNullException(nameof(discard));
|
||||
}
|
||||
// Synchronous main-thread region: no callback may yield between Reset and publication.
|
||||
internal PlayerKVApplyError Apply(PlayerKVDecodedSave decoded,Func<bool> current,Func<bool> clean,Action notify)
|
||||
{
|
||||
if(Poisoned) return PlayerKVApplyError.Poisoned;
|
||||
if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread || decoded==null || current==null || clean==null || notify==null)
|
||||
return PlayerKVApplyError.Input;
|
||||
applying=true; AppliedModules=0;
|
||||
PlayerKVApplyError error=PlayerKVApplyError.Apply;
|
||||
try
|
||||
{
|
||||
if(!current()) error=PlayerKVApplyError.Stale;
|
||||
else if(!clean()) error=PlayerKVApplyError.SideEffect;
|
||||
else if(!ResetAll()) error=PlayerKVApplyError.Poisoned;
|
||||
else
|
||||
{
|
||||
foreach(var module in modules)
|
||||
{
|
||||
if(!current()) { error=PlayerKVApplyError.Stale; break; }
|
||||
if(!clean()) { error=PlayerKVApplyError.SideEffect; break; }
|
||||
module.Replace(decoded); AppliedModules++;
|
||||
}
|
||||
if(AppliedModules==31)
|
||||
{
|
||||
// The last module can revoke the epoch or trip a guard; never publish in that gap.
|
||||
if(!current()) error=PlayerKVApplyError.Stale;
|
||||
else if(!clean()) error=PlayerKVApplyError.SideEffect;
|
||||
else
|
||||
{
|
||||
notify();
|
||||
error=!current() ? PlayerKVApplyError.Stale : !clean() ? PlayerKVApplyError.SideEffect : PlayerKVApplyError.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { error=PlayerKVApplyError.Apply; }
|
||||
finally { applying=false; }
|
||||
if(error!=PlayerKVApplyError.None)
|
||||
{
|
||||
if(!ResetAll()) error=PlayerKVApplyError.Poisoned;
|
||||
try { discard(); } catch { Poisoned=true; error=PlayerKVApplyError.Poisoned; }
|
||||
AppliedModules=0;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
internal bool Clear()
|
||||
{
|
||||
if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread) throw new InvalidOperationException("Main thread required.");
|
||||
bool cleared=ResetAll();
|
||||
try { discard(); } catch { Poisoned=true; cleared=false; }
|
||||
AppliedModules=0; return cleared;
|
||||
}
|
||||
bool ResetAll()
|
||||
{
|
||||
bool ok=true;
|
||||
foreach(var module in modules) try { module.Reset(); } catch { ok=false; }
|
||||
if(!ok) Poisoned=true;
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1ea0dd4977649169863e3c917802188
|
||||
@@ -0,0 +1,276 @@
|
||||
using System;
|
||||
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 }) { }
|
||||
|
||||
internal PlayerKVGameSessionBridge(Func<CookieContainer, HttpMessageHandler> handlerFactory)
|
||||
{ this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); }
|
||||
|
||||
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);
|
||||
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) =>
|
||||
slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore);
|
||||
public PlayerKVEditResult Delete(string key) => slot.Revoked ? BlockedEdit() : Owner.Delete(key);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebfda20b77ad4dc1ac25f16acffb9995
|
||||
@@ -0,0 +1,60 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using asap.core;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Bind the existing game container. Missing registrations fail closed; no parallel fixture objects.
|
||||
internal static class PlayerKVGameplayBindings
|
||||
{
|
||||
static T Require<T>() where T:class => GContext.container.Resolve<T>() ??
|
||||
throw new InvalidOperationException("KV game module is not registered: "+typeof(T).FullName);
|
||||
internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc)
|
||||
{
|
||||
var tables=Require<cfg.Tables>();
|
||||
return new PlayerKVSchemaSnapshot("kv-loop",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
|
||||
Enum.GetNames(typeof(cfg.ConditionType)).ToDictionary(n=>n,n=>(int)Enum.Parse(typeof(cfg.ConditionType),n)),CultureInfo.InvariantCulture,
|
||||
nowUtc,tables.TbGlobalConfig.InitWheelTicket,
|
||||
tables.TbEventPackManager.DataMap.ToDictionary(p=>p.Key,p=>p.Value.PackType),
|
||||
tables.TbFishingEvent.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVEventConfig(p.Value.RedirectID,(p.Value.TimeDefinition as cfg.LimitedTime)?.EndTime)),tables.TbGlobalConfig.GlobalTimeOffset,
|
||||
tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)),
|
||||
tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList)));
|
||||
}
|
||||
internal static PlayerKVGameLoadTransaction Bind(PlayFabUserService user)
|
||||
{
|
||||
var player=Require<GameCore.PlayerData>(); var small=Require<GameCore.SmallGameData>(); var fish=Require<GameCore.PlayerFishData>();
|
||||
var shop=Require<GameCore.PlayerShopData>(); var eventBase=Require<GameCore.FishingEventData>(); var buffs=Require<BuffDataCenter>(); var camp=Require<CampDataMM>();
|
||||
var achievements=Require<AchievementDataManager>(); var box=Require<FishingBoxData>(); var guide=Require<GameCore.GuideDataCenter>(); var battle=Require<BattlePassData>();
|
||||
var sign=Require<GameCore.SigninData>(); var trigger=Require<GameCore.TriggerPackData>(); var club=Require<ClubService>(); var challenge=Require<FishingChallengeManager>();
|
||||
var chains=Require<DataCenter.OfferChainsChestManager>(); var disco=Require<DataCenter.OfferDiscoPartyManager>(); var partner=Require<DataCenter.EventPartnerGatherManager>(); var cube=Require<EventCubeMeltManager>();
|
||||
var eventPack=Require<EventPackData>(); var piggy=Require<PiggyBankPackData>(); var bargain=Require<BargainPackData>(); var foot=Require<IFootPrintService>() as FootPrintService ?? throw new InvalidOperationException("M23 FootPrint binding mismatch."); var shooting=Require<EventShootingRangeData>();
|
||||
var potion=Require<EventPotionDataManager>(); var pinball=Require<EventPinballDataManager>(); var scratch=Require<EventScratchTicketDataManager>();
|
||||
var progress=eventBase.ThanksGivingPackData; var migration=eventBase.ChainPackWithProgressMigrationData;
|
||||
var pinata=Require<EventPinataPackDataManager>(); var digging=Require<Script.RuntimeScript.DiggingGameManager>();
|
||||
var modules=new List<IPlayerKVLoadModule>();
|
||||
void Add<T>(int id,Action reset,Action<T> replace) where T:class => modules.Add(new PlayerKVLoadModule(id,reset,d=>replace(d.Module<T>(id))));
|
||||
Add<PlayerKVPlayerState>(1,player.ResetForKVLoad,player.ReplaceFromKVDecoded); Add<PlayerKVCoreState>(2,small.ResetForKVLoad,small.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVFishState>(3,fish.ResetForKVLoad,fish.ReplaceFromKVDecoded); Add<PlayerKVShopState>(4,shop.ResetForKVLoad,shop.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVEventBaseState>(5,eventBase.ResetForKVLoad,eventBase.ReplaceFromKVDecoded); Add<PlayerKVBuffState>(6,buffs.ResetForKVLoad,buffs.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVCampState>(7,camp.ResetForKVLoad,camp.ReplaceFromKVDecoded); Add<PlayerKVAchievementState>(8,achievements.ResetForKVLoad,achievements.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVIntMapState>(9,box.ResetForKVLoad,box.ReplaceFromKVDecoded); Add<PlayerKVGuideState>(10,guide.ResetForKVLoad,guide.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVBattlePassState>(11,battle.ResetForKVLoad,battle.ReplaceFromKVDecoded); Add<PlayerKVMonthlySignState>(12,sign.ResetForKVLoad,sign.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVTriggerState>(13,trigger.ResetForKVLoad,trigger.ReplaceFromKVDecoded); Add<PlayerKVClubState>(14,club.ResetForKVLoad,club.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVChallengeState>(15,challenge.ResetForKVLoad,challenge.ReplaceFromKVDecoded); Add<PlayerKVOfferState>(16,chains.ResetForKVLoad,chains.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVOfferState>(17,disco.ResetForKVLoad,disco.ReplaceFromKVDecoded); Add<PlayerKVPartnerState>(18,partner.ResetForKVLoad,partner.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVCubeState>(19,cube.ResetForKVLoad,cube.ReplaceFromKVDecoded); Add<PlayerKVEventPackState>(20,eventPack.ResetForKVLoad,eventPack.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVPiggyBankState>(21,piggy.ResetForKVLoad,piggy.ReplaceFromKVDecoded); Add<PlayerKVBargainState>(22,bargain.ResetForKVLoad,bargain.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVFootPrintState>(23,foot.ResetForKVLoad,foot.ReplaceFromKVDecoded); Add<PlayerKVShootingRangeState>(24,shooting.ResetForKVLoad,shooting.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVPotionState>(25,potion.ResetForKVLoad,potion.ReplaceFromKVDecoded); Add<PlayerKVPinballState>(26,pinball.ResetForKVLoad,pinball.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVScratchState>(27,scratch.ResetForKVLoad,scratch.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVProgressPackPair>(28,()=>{progress.ResetForKVLoad();migration.ResetForKVLoad();},p=>{progress.ReplaceFromKVDecoded(p.Current);migration.ReplaceFromKVDecoded(p.Migration);});
|
||||
Add<PlayerKVPinataState>(29,pinata.ResetForKVLoad,pinata.ReplaceFromKVDecoded); Add<PlayerKVEmptyModuleState>(30,digging.ResetForKVLoad,digging.ReplaceFromKVDecoded);
|
||||
modules.Add(new PlayerKVLoadModule(31,user.ResetForKVLoad,d=>user.ReplaceFromKVDecoded(d.Module<PlayerKVLoginState>(31),d.Epoch,d.Raw)));
|
||||
return new PlayerKVGameLoadTransaction(modules,System.Threading.Thread.CurrentThread.ManagedThreadId,()=>user.KVIntents?.Discard());
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7803da4435ea45c28007ff0198c676fb
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Only two approved in-memory normalizations. Never a persistence receipt or retry queue.
|
||||
internal sealed class PlayerKVInitializationIntents
|
||||
{
|
||||
readonly Dictionary<string,string> values=new Dictionary<string,string>(StringComparer.Ordinal);
|
||||
readonly string channel;
|
||||
internal long Epoch { get; }
|
||||
internal bool Discarded { get; private set; }
|
||||
internal int DiscardedCount { get; private set; }
|
||||
internal int Count => values.Count;
|
||||
internal IReadOnlyDictionary<string,string> Snapshot => new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(values));
|
||||
internal PlayerKVInitializationIntents(long epoch,PlayerKVIdentityChannel channel)
|
||||
{
|
||||
if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel)) throw new ArgumentException("Unknown intent channel.");
|
||||
Epoch=epoch; this.channel=channel==PlayerKVIdentityChannel.hwGuest || channel==PlayerKVIdentityChannel.none ? null : channel.ToString();
|
||||
}
|
||||
internal void Add(long epoch,string key,string value)
|
||||
{
|
||||
if(Discarded || epoch!=Epoch || key==null || (key!="MMTest" && key!=channel) || value==null)
|
||||
throw new InvalidOperationException("Initialization intent rejected.");
|
||||
values[key]=value;
|
||||
}
|
||||
internal void Discard() { if(Discarded) return; DiscardedCount=values.Count; values.Clear(); Discarded=true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6215961788794950aff0709dbb499793
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 916120c58a7f4f71bf59e17a4a045bb8
|
||||
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4bbcb1b86b2b4301af3d7518408edd6f
|
||||
@@ -0,0 +1,472 @@
|
||||
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;
|
||||
|
||||
// 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>Construction is absent from player builds; no default network handler is supplied.</summary>
|
||||
internal sealed class PlayerKVControlledLoadCapability
|
||||
{
|
||||
readonly Func<CookieContainer, HttpMessageHandler> leaseHandler;
|
||||
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler)
|
||||
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); }
|
||||
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVControlledLoadCapability CreateForValidation(
|
||||
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler);
|
||||
#endif
|
||||
|
||||
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
|
||||
{
|
||||
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Synthetic 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 (node is HttpClientHandler ||
|
||||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Real or cyclic transport is not a validation 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 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 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.current = current; this.admission = admission;
|
||||
client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api };
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVAccountResponse> SendAsync(HttpMethod method, string route,
|
||||
string body = null, string contentType = "application/json", CancellationToken token = default)
|
||||
{
|
||||
// Exact canonical relative paths: no query, fragment, encoding, traversal or absolute URL.
|
||||
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 validation 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1cc96c3a86184fd9b723896a55c6a929
|
||||
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2c0df39903a45f780cc6751d1774c51
|
||||
31
Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs
Normal file
31
Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
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; }
|
||||
// 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)
|
||||
{
|
||||
Error = error;
|
||||
StatusCode = statusCode;
|
||||
Data = data == null ? null : new ReadOnlyDictionary<string, string>(data);
|
||||
MayHaveCommitted = mayHaveCommitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9401473b45424dbf94ddece933db1c86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,126 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal static class PlayerKVSaveRouteEditorValidationHost
|
||||
{
|
||||
static int running;
|
||||
// Retain a failed cleanup's owner for diagnosis; never silently restart over it.
|
||||
static object failedLifetime;
|
||||
internal static async Task<string> RunAsync(string caseId)
|
||||
{
|
||||
if (caseId != "fixed-route") throw new ArgumentException("Unknown fixed validation case.");
|
||||
if (Interlocked.CompareExchange(ref running, 1, 0) != 0 || failedLifetime != null)
|
||||
throw new InvalidOperationException("Validation already running or cleanup failed.");
|
||||
var run = new Run();
|
||||
try { return await run.Execute(); }
|
||||
finally
|
||||
{
|
||||
try { await run.Close(); }
|
||||
catch { failedLifetime = run; throw; }
|
||||
finally { Interlocked.Exchange(ref running, 0); }
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Run
|
||||
{
|
||||
readonly Uri api = new Uri("http://kv-route.invalid/");
|
||||
readonly List<S101AccountSession> sessions = new List<S101AccountSession>();
|
||||
readonly Dictionary<string, Dictionary<string, string>> remote = new Dictionary<string, Dictionary<string, string>>
|
||||
{ ["101"] = new Dictionary<string, string> { ["k"] = "A", ["empty"] = "" }, ["102"] = new Dictionary<string, string> { ["k"] = "B" } };
|
||||
readonly string cache = Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath,
|
||||
"../../CodexOutput/upserver/KVSaveRouteTests/unity-runs", DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N")));
|
||||
PlayerKVGameSessionBridge bridge;
|
||||
PlayerKVGameLoadGuard guard;
|
||||
int assertions, auth, gets, writes;
|
||||
long epoch = 1;
|
||||
void Check(bool value) { assertions++; if (!value) throw new InvalidOperationException("Fixed route assertion " + assertions); }
|
||||
static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{ Content = new StringContent(JsonConvert.SerializeObject(value)) };
|
||||
sealed class SyntheticHandler : HttpMessageHandler
|
||||
{
|
||||
readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> send;
|
||||
internal SyntheticHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) { this.send = send; }
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) => send(request);
|
||||
}
|
||||
HttpMessageHandler KV(CookieContainer jar)
|
||||
{
|
||||
var id = jar.GetCookies(api)["sid"].Value;
|
||||
return new SyntheticHandler(async request =>
|
||||
{
|
||||
Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.Port == 80);
|
||||
var path = request.RequestUri.AbsolutePath;
|
||||
Check(path == "/playerdata/Get" || path == "/playerdata/Set");
|
||||
var body = JObject.Parse(await request.Content.ReadAsStringAsync());
|
||||
if (path == "/playerdata/Get") { gets++; return Json(new { data = remote[id] }); }
|
||||
writes++;
|
||||
foreach (var p in ((JObject)body["data"]).Properties()) remote[id][p.Name] = (string)p.Value;
|
||||
return Json(new { });
|
||||
});
|
||||
}
|
||||
async Task<PlayerKVGameSessionHandle> Open(string id)
|
||||
{
|
||||
var session = new S101AccountSession(api, jar => new SyntheticHandler(request =>
|
||||
{
|
||||
Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.AbsolutePath == "/account/Login");
|
||||
auth++; jar.Add(api, new Cookie("sid", id));
|
||||
return Task.FromResult(Json(new { state = 0, internalId = int.Parse(id), newCreate = false,
|
||||
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" }));
|
||||
}));
|
||||
sessions.Add(session);
|
||||
Check((await session.LoginAsync(id)).Success);
|
||||
var opened = await bridge.OpenAsync(session, cache, id, api, epoch, e => e == epoch);
|
||||
Check(opened.Success); return opened.Handle;
|
||||
}
|
||||
internal async Task<string> Execute()
|
||||
{
|
||||
// Never disturb a guard held by another validation or a controlled login.
|
||||
Check(PlayerKVGameLoadGuard.TryEnter(out guard)); guard.Dispose(); guard = null;
|
||||
Directory.CreateDirectory(cache); bridge = new PlayerKVGameSessionBridge(KV);
|
||||
var a = await Open("101");
|
||||
var present = PlayFabMgr.ReadKV(a, "k"); Check(present.Success && present.Found == true && present.Value == "A");
|
||||
Check(PlayFabMgr.ReadKV(a, "missing").Found == false);
|
||||
Check(PlayFabMgr.ReadKV(a, "empty").Found == true && PlayFabMgr.ReadKV(a, "empty").Value == "");
|
||||
Check(PlayFabMgr.ReadKV(a, null).Found == null);
|
||||
var edit = PlayFabMgr.WriteKV(a, "k", "remote"); Check(edit.Accepted && edit.Ticket != null && writes == 0);
|
||||
var local = PlayFabMgr.WriteKV(a, "k", "local", false); Check(local.Accepted && local.Ticket == null);
|
||||
Check(PlayFabMgr.ReadKV(a, "k").Value == "local");
|
||||
var flush = await PlayFabMgr.FlushKVAsync(a); Check(flush.Confirmed == 1 && remote["101"]["k"] == "remote");
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(a, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed);
|
||||
await bridge.CloseAsync(); epoch++;
|
||||
var b = await Open("102"); var writesBefore = writes;
|
||||
Check(PlayFabMgr.ReadKV(a, "k").Error == PlayerKVKeyReadError.StaleOrClosing);
|
||||
Check(!PlayFabMgr.WriteKV(a, "k", "late").Accepted);
|
||||
Check((await PlayFabMgr.FlushKVAsync(a)).Blocked == 1);
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(b, edit.Ticket)).Status == PlayerKVTicketStatus.Blocked);
|
||||
Check(PlayFabMgr.ReadKV(b, "k").Value == "B" && writes == writesBefore);
|
||||
Check(PlayerKVGameLoadGuard.TryEnter(out guard));
|
||||
Check(PlayFabMgr.ReadKV(null, null).Error == PlayerKVKeyReadError.LoadGuardActive);
|
||||
Check(PlayFabMgr.WriteKV(null, null, null).StateResult.Error == PlayerKVChangeError.Blocked);
|
||||
Check((await PlayFabMgr.FlushKVAsync(b)).TransportError == PlayerKVError.Cancelled);
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(b, null)).TransportError == PlayerKVError.Cancelled);
|
||||
Check(guard.Violations == 4 && guard.Count(PlayerKVGuardPoint.ReadLocal) == 1 && guard.Count(PlayerKVGuardPoint.Update) == 1 && guard.Count(PlayerKVGuardPoint.Sync) == 2);
|
||||
Check(auth == 2 && gets == 2 && writes == 1);
|
||||
await Close();
|
||||
return JsonConvert.SerializeObject(new { success = true, assertions, auth, gets, writes, formalRequests = 0, restored = true, cache });
|
||||
}
|
||||
internal async Task Close()
|
||||
{
|
||||
if (bridge != null) await bridge.CloseAsync();
|
||||
foreach (var session in sessions) session.Dispose(); sessions.Clear();
|
||||
guard?.Dispose(); guard = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 860b04f8a2a64eb8acf77e96695f6c54
|
||||
1168
Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs
Normal file
1168
Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c49515cb90840a189772d23ad69fd2b
|
||||
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
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)
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore);
|
||||
}
|
||||
|
||||
public PlayerKVEditResult Delete(string key)
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
return lease == null ? ClosedEdit() : coordinator.Delete(key);
|
||||
}
|
||||
|
||||
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)) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e46e2622d9b94d61aeb796d53cea5cdc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c524f69ad00844b4a1d0456b4d3c43ae
|
||||
@@ -0,0 +1,339 @@
|
||||
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 PlayerKVError TransportError { get; }
|
||||
public PlayerKVSyncStatus CacheStatus { get; }
|
||||
internal PlayerKVFlushResult(int confirmed, int superseded, int blocked, int unknown,
|
||||
PlayerKVError transportError, PlayerKVSyncStatus cacheStatus)
|
||||
{
|
||||
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;
|
||||
bool disposed;
|
||||
|
||||
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()
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
try
|
||||
{
|
||||
if (!Current()) return CancelledResult();
|
||||
var result = await client.GetAsync(null, ownerToken);
|
||||
if (!Current()) return CancelledResult();
|
||||
if (result.Success)
|
||||
{
|
||||
var reconciled = state.Reconcile(result.Data);
|
||||
return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol);
|
||||
}
|
||||
state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized);
|
||||
return result;
|
||||
}
|
||||
finally { dispatch.Release(); }
|
||||
}
|
||||
|
||||
internal PlayerKVEditResult Update(string key, string value, bool remoteStore)
|
||||
{
|
||||
var result = state.Update(key, value, remoteStore);
|
||||
if (!result.Success || !remoteStore) return new PlayerKVEditResult(result);
|
||||
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
|
||||
lock (ticketGate)
|
||||
{
|
||||
SupersedeUnsentForKey(key, result.AttemptSequence);
|
||||
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
|
||||
}
|
||||
return new PlayerKVEditResult(result, ticket);
|
||||
}
|
||||
|
||||
internal PlayerKVEditResult Delete(string key)
|
||||
{
|
||||
var result = state.Delete(key);
|
||||
if (!result.Success) return new PlayerKVEditResult(result);
|
||||
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
|
||||
lock (ticketGate)
|
||||
{
|
||||
SupersedeUnsentForKey(key, result.AttemptSequence);
|
||||
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
|
||||
}
|
||||
return new PlayerKVEditResult(result, ticket);
|
||||
}
|
||||
|
||||
internal long CaptureWatermark(out long[] targetSequences)
|
||||
{
|
||||
var next = state.Inspect().NextSequence;
|
||||
var watermark = next <= 1 ? 0 : next - 1;
|
||||
lock (ticketGate)
|
||||
{
|
||||
targetSequences = tickets.Values.Where(record => record.Ticket.Sequence <= watermark && !record.ClaimedByFlush)
|
||||
.Select(record => { record.ClaimedByFlush = true; return record.Ticket.Sequence; }).ToArray();
|
||||
}
|
||||
return watermark;
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVFlushResult> FlushThroughAsync(long watermark, long[] targetSequences)
|
||||
{
|
||||
PlayerKVError finalError = PlayerKVError.None;
|
||||
var targetSet = new HashSet<long>(targetSequences ?? Array.Empty<long>());
|
||||
var recoveredConfirmed = 0;
|
||||
var recoveredBlocked = 0;
|
||||
var recoveredUnknown = 0;
|
||||
var acquired = false;
|
||||
try
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
acquired = true;
|
||||
while (Current())
|
||||
{
|
||||
MarkSuperseded(watermark);
|
||||
var begin = state.BeginBatch(watermark, out var batch);
|
||||
if (!begin.Success)
|
||||
{
|
||||
recoveredBlocked += state.Inspect().Pending.Values.Count(item =>
|
||||
item.Sequence <= watermark && !targetSet.Contains(item.Sequence));
|
||||
MarkEligibleBlocked(watermark, PlayerKVError.None);
|
||||
break;
|
||||
}
|
||||
if (batch == null) break;
|
||||
MarkDispatched(batch);
|
||||
PlayerKVResult result;
|
||||
if (batch.IsDelete)
|
||||
result = await client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken);
|
||||
else
|
||||
result = await client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value,
|
||||
StringComparer.Ordinal), ownerToken);
|
||||
|
||||
if (!Current() && result.Success)
|
||||
result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var confirmed = state.ConfirmBatch(batch.Id);
|
||||
if (!confirmed.Success)
|
||||
{
|
||||
CompleteBatch(batch, PlayerKVTicketStatus.Unknown, PlayerKVError.None);
|
||||
recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
finalError = PlayerKVError.None;
|
||||
break;
|
||||
}
|
||||
CompleteBatch(batch, PlayerKVTicketStatus.Confirmed, PlayerKVError.None);
|
||||
recoveredConfirmed += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
continue;
|
||||
}
|
||||
|
||||
finalError = result.Error;
|
||||
var failed = state.FailBatch(batch.Id, result.MayHaveCommitted,
|
||||
result.Error == PlayerKVError.Unauthorized);
|
||||
var status = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
|
||||
ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked;
|
||||
CompleteBatch(batch, status, result.Error);
|
||||
if (status == PlayerKVTicketStatus.Unknown)
|
||||
recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
else
|
||||
recoveredBlocked += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
if (!failed.Success && status != PlayerKVTicketStatus.Unknown)
|
||||
MarkEligibleBlocked(watermark, result.Error);
|
||||
break;
|
||||
}
|
||||
if (!Current())
|
||||
{
|
||||
if (finalError == PlayerKVError.None) finalError = PlayerKVError.Cancelled;
|
||||
MarkEligibleOnClose(watermark);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
finalError = PlayerKVError.Cancelled;
|
||||
MarkEligibleOnClose(watermark);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
finalError = PlayerKVError.Protocol;
|
||||
MarkEligibleBlocked(watermark, finalError);
|
||||
}
|
||||
finally { if (acquired) dispatch.Release(); }
|
||||
return Summary(targetSequences ?? Array.Empty<long>(), finalError,
|
||||
recoveredConfirmed, recoveredBlocked, recoveredUnknown);
|
||||
}
|
||||
|
||||
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 SupersedeUnsentForKey(string key, long newerSequence)
|
||||
{
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Key == key && record.Ticket.Sequence < newerSequence && !record.Dispatched && !record.Completion.Task.IsCompleted)
|
||||
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
|
||||
}
|
||||
|
||||
void MarkSuperseded(long watermark)
|
||||
{
|
||||
var snapshot = state.Inspect();
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Sequence <= watermark && !record.Dispatched && !record.Completion.Task.IsCompleted &&
|
||||
snapshot.Pending.TryGetValue(record.Ticket.Key, out var latest) && latest.Sequence > record.Ticket.Sequence)
|
||||
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
|
||||
}
|
||||
|
||||
void MarkEligibleBlocked(long watermark, PlayerKVError error)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted)
|
||||
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked, error);
|
||||
}
|
||||
|
||||
void MarkEligibleOnClose(long watermark)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted)
|
||||
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled,
|
||||
PlayerKVError.Cancelled);
|
||||
}
|
||||
|
||||
void Complete(TicketRecord record, PlayerKVTicketStatus status, PlayerKVError error) =>
|
||||
record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status));
|
||||
|
||||
PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error,
|
||||
int recoveredConfirmed, int recoveredBlocked, int recoveredUnknown)
|
||||
{
|
||||
var counts = new Dictionary<PlayerKVTicketStatus, int>();
|
||||
lock (ticketGate)
|
||||
foreach (var sequence in targetSequences)
|
||||
{
|
||||
if (!tickets.TryGetValue(sequence, out var record)) continue;
|
||||
var status = record.Completion.Task.IsCompleted
|
||||
? record.Completion.Task.GetAwaiter().GetResult().Status : PlayerKVTicketStatus.Pending;
|
||||
counts[status] = counts.TryGetValue(status, out var count) ? count + 1 : 1;
|
||||
}
|
||||
Func<PlayerKVTicketStatus, int> countOf = status => counts.TryGetValue(status, out var count) ? count : 0;
|
||||
return new PlayerKVFlushResult(countOf(PlayerKVTicketStatus.Confirmed) + recoveredConfirmed,
|
||||
countOf(PlayerKVTicketStatus.Superseded),
|
||||
countOf(PlayerKVTicketStatus.Blocked) + countOf(PlayerKVTicketStatus.Cancelled) + recoveredBlocked,
|
||||
countOf(PlayerKVTicketStatus.Unknown) + recoveredUnknown, error, state.Status);
|
||||
}
|
||||
|
||||
bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent();
|
||||
static PlayerKVResult CancelledResult() => new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
http.Dispose();
|
||||
dispatch.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ff223eaf9574c4894c4acbb6b06a387
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
249
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs
Normal file
249
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
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;
|
||||
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items)
|
||||
{ Id = id; Items = Array.AsReadOnly(items.ToArray()); }
|
||||
}
|
||||
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
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> LocalOnly = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, PlayerKVChange> Pending = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, PlayerKVValue> Conflicts = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal);
|
||||
internal PlayerKVBatch Active, Unknown;
|
||||
internal KVSnapshot Clone() => new KVSnapshot {
|
||||
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid,
|
||||
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal), LocalOnly = new Dictionary<string, string>(LocalOnly, StringComparer.Ordinal),
|
||||
Pending = new Dictionary<string, PlayerKVChange>(Pending, StringComparer.Ordinal), Conflicts = new Dictionary<string, PlayerKVValue>(Conflicts, StringComparer.Ordinal),
|
||||
Active = Active, Unknown = Unknown
|
||||
};
|
||||
internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128;
|
||||
|
||||
internal string Encode(KVIdentity identity)
|
||||
{
|
||||
var pending = new JArray(Pending.Values.OrderBy(p => p.Sequence).Select(ChangeJson));
|
||||
var conflicts = new JObject();
|
||||
foreach (var pair in Conflicts) conflicts.Add(pair.Key, ValueJson(pair.Value));
|
||||
return new JObject {
|
||||
["formatVersion"] = 1, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId,
|
||||
["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline,
|
||||
["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline),
|
||||
["localOnly"] = JObject.FromObject(LocalOnly), ["pending"] = pending, ["conflictObservations"] = conflicts,
|
||||
["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown)
|
||||
}.ToString(Formatting.None);
|
||||
}
|
||||
static JObject ValueJson(PlayerKVValue value) => new JObject { ["exists"] = value.Exists, ["value"] = value.Value };
|
||||
static JObject ChangeJson(PlayerKVChange item) => new JObject {
|
||||
["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target)
|
||||
};
|
||||
static JToken BatchJson(PlayerKVBatch batch) => batch == null ? JValue.CreateNull() : (JToken)new JObject {
|
||||
["id"] = batch.Id, ["items"] = new JArray(batch.Items.Select(ChangeJson))
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
if (Integer(root, "formatVersion") != 1 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId) throw Invalid();
|
||||
var result = new KVSnapshot {
|
||||
Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"),
|
||||
HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"),
|
||||
Baseline = Strings(Field(root, "baseline", JTokenType.Object)), LocalOnly = Strings(Field(root, "localOnly", JTokenType.Object)),
|
||||
Active = Batch(root, "activeBatch"), Unknown = Batch(root, "unknownBatch")
|
||||
};
|
||||
foreach (var item in Field(root, "pending", JTokenType.Array))
|
||||
{
|
||||
var change = Change(item);
|
||||
if (result.Pending.ContainsKey(change.Key)) throw Invalid();
|
||||
result.Pending.Add(change.Key, change);
|
||||
}
|
||||
foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name)) throw Invalid();
|
||||
result.Conflicts.Add(property.Name, Value(property.Value));
|
||||
}
|
||||
result.Validate();
|
||||
return result;
|
||||
}
|
||||
internal void Validate()
|
||||
{
|
||||
if (Revision < 1 || NextSequence < 1 || (Active != null && Unknown != null) ||
|
||||
(!HasBaseline && (Baseline.Count != 0 || LocalOnly.Count != 0 || Pending.Count != 0 || Active != null || Unknown != null))) throw Invalid();
|
||||
var sequences = new Dictionary<long, string>();
|
||||
foreach (var item in Pending.Values)
|
||||
{
|
||||
if (item.Sequence < 1 || item.Sequence >= NextSequence || sequences.ContainsKey(item.Sequence)) throw Invalid();
|
||||
sequences.Add(item.Sequence, item.Key);
|
||||
}
|
||||
foreach (var key in Conflicts.Keys) if (!Pending.ContainsKey(key)) throw Invalid();
|
||||
var batch = Active ?? Unknown;
|
||||
if (batch == null) return;
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
if (item.Sequence < 1 || item.Sequence >= NextSequence || !Pending.TryGetValue(item.Key, out var latest) || latest.Sequence < item.Sequence ||
|
||||
(sequences.TryGetValue(item.Sequence, out var key) && key != item.Key) ||
|
||||
!latest.BaseValue.Equals(item.BaseValue) ||
|
||||
(latest.Sequence == item.Sequence && !latest.Target.Equals(item.Target))) throw Invalid();
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c949512a03dc431b94569c20f06102af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
203
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs
Normal file
203
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
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
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly PlayerKVCacheStore store;
|
||||
KVSnapshot snapshot;
|
||||
PlayerKVSyncStatus status;
|
||||
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;
|
||||
// Authentication belongs to the old owner. A new owner must obtain a fresh
|
||||
// successful Get before Ready; this never clears a persisted unknown batch.
|
||||
snapshot.AuthenticationInvalid = false;
|
||||
// A recovered active batch might have reached the server. Never acknowledge it by guessing.
|
||||
if (snapshot.Active != null) { snapshot.Unknown = snapshot.Active; snapshot.Active = null; }
|
||||
status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded;
|
||||
}
|
||||
|
||||
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.");
|
||||
var result = new Dictionary<string, string>(snapshot.Baseline, StringComparer.Ordinal);
|
||||
foreach (var item in snapshot.Pending.Values) Apply(result, item.Key, item.Target);
|
||||
foreach (var pair in snapshot.LocalOnly) result[pair.Key] = pair.Value;
|
||||
return new ReadOnlyDictionary<string, string>(result);
|
||||
}
|
||||
}
|
||||
|
||||
// The caller must supply a successful complete Get. Failed reads must use RecordReadFailure.
|
||||
public PlayerKVStateResult Reconcile(IReadOnlyDictionary<string, string> remote)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (remote == null || remote.Any(p => !KVSnapshot.ValidKey(p.Key) || p.Value == null)) return Invalid();
|
||||
var copy = snapshot.Clone();
|
||||
copy.Conflicts.Clear();
|
||||
if (copy.Unknown != null)
|
||||
{
|
||||
foreach (var item in copy.Pending.Values) copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key);
|
||||
return Commit(copy, PlayerKVSyncStatus.UnknownOutcome);
|
||||
}
|
||||
foreach (var item in copy.Pending.Values.ToArray())
|
||||
{
|
||||
var observed = PlayerKVValue.At(remote, item.Key);
|
||||
if (observed.Equals(item.Target)) copy.Pending.Remove(item.Key);
|
||||
else if (!observed.Equals(item.BaseValue)) copy.Conflicts[item.Key] = observed;
|
||||
}
|
||||
copy.Baseline = remote.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
|
||||
copy.HasBaseline = true;
|
||||
return Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict);
|
||||
}
|
||||
}
|
||||
|
||||
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) =>
|
||||
Edit(key, value == null ? null : PlayerKVValue.Present(value), remoteStore);
|
||||
public PlayerKVStateResult Delete(string key) => Edit(key, PlayerKVValue.Missing, true);
|
||||
|
||||
PlayerKVStateResult Edit(string key, PlayerKVValue target, bool remote)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (!KVSnapshot.ValidKey(key) || target == null || snapshot.NextSequence == long.MaxValue) return Invalid();
|
||||
var copy = snapshot.Clone();
|
||||
var sequence = copy.NextSequence++;
|
||||
if (remote)
|
||||
{
|
||||
var baseline = copy.Pending.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key);
|
||||
copy.Pending[key] = new PlayerKVChange(key, sequence, baseline, target);
|
||||
copy.LocalOnly.Remove(key);
|
||||
}
|
||||
else copy.LocalOnly[key] = target.Value;
|
||||
return Commit(copy, PlayerKVSyncStatus.Ready, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
batch = null;
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (throughSequence < 0) return Invalid();
|
||||
var ordered = snapshot.Pending.Values.Where(p => p.Sequence <= throughSequence).OrderBy(p => p.Sequence).ToArray();
|
||||
if (ordered.Length == 0) return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
|
||||
var copy = snapshot.Clone();
|
||||
copy.Active = new PlayerKVBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(p => p.Target.Exists == ordered[0].Target.Exists).Take(10));
|
||||
var result = Commit(copy, PlayerKVSyncStatus.Ready);
|
||||
if (result.Success) batch = snapshot.Active; // Never expose a batch until its write-ahead record is durable.
|
||||
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);
|
||||
var current = copy.Pending[item.Key];
|
||||
if (current.Sequence == item.Sequence) copy.Pending.Remove(item.Key);
|
||||
else copy.Pending[item.Key] = current.WithBase(item.Target);
|
||||
}
|
||||
copy.Active = null;
|
||||
return Commit(copy, PlayerKVSyncStatus.Ready);
|
||||
}
|
||||
}
|
||||
|
||||
// mayHaveCommitted=false is only valid with positive evidence of no dispatch.
|
||||
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();
|
||||
copy.AuthenticationInvalid |= unauthorized;
|
||||
if (mayHaveCommitted) copy.Unknown = copy.Active;
|
||||
copy.Active = null;
|
||||
return Commit(copy, mayHaveCommitted ? PlayerKVSyncStatus.UnknownOutcome :
|
||||
copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.ReadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
store.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0aabf3ee88684f80ba8770cbf96a9be8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user