Files
ft/Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs

250 lines
14 KiB
C#

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.");
}
}