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 { 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 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 Items { get; } public bool IsDelete => !Items[0].Target.Exists; internal PlayerKVBatch(string id, IEnumerable 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 Baseline { get; } public IReadOnlyDictionary LocalOnly { get; } public IReadOnlyDictionary Pending { get; } public IReadOnlyDictionary 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 ReadOnly(Dictionary source) => new ReadOnlyDictionary(new Dictionary(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 Baseline = new Dictionary(StringComparer.Ordinal); internal Dictionary LocalOnly = new Dictionary(StringComparer.Ordinal); internal Dictionary Pending = new Dictionary(StringComparer.Ordinal); internal Dictionary Conflicts = new Dictionary(StringComparer.Ordinal); internal PlayerKVBatch Active, Unknown; internal KVSnapshot Clone() => new KVSnapshot { Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid, Baseline = new Dictionary(Baseline, StringComparer.Ordinal), LocalOnly = new Dictionary(LocalOnly, StringComparer.Ordinal), Pending = new Dictionary(Pending, StringComparer.Ordinal), Conflicts = new Dictionary(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(); 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 Strings(JToken value) { var result = new Dictionary(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."); } }