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; public bool IsOrdinary { get; } internal PlayerKVBatch(string id, IEnumerable items, bool ordinary = false) { Id = id; Items = Array.AsReadOnly(items.ToArray()); IsOrdinary = ordinary; } } public sealed class PlayerKVDiagnosticSnapshot { public long Revision { get; } public long NextSequence { get; } public bool HasBaseline { get; } public bool AuthenticationInvalid { get; } public IReadOnlyDictionary Baseline { get; } public IReadOnlyDictionary LocalOnly { get; } public IReadOnlyDictionary Pending { get; } public IReadOnlyDictionary ConflictObservations { get; } public PlayerKVBatch ActiveBatch { get; } public PlayerKVBatch UnknownBatch { get; } public IReadOnlyDictionary LocalData { get; } public IReadOnlyDictionary LocalUpdateTicks { get; } public IReadOnlyList> OrdinaryBuffers { get; } public IReadOnlyList PreparedOrdinaryBatches { get; } public IReadOnlyList OrdinaryUnknownBatches { get; } public IReadOnlyDictionary LegacyPending { get; } public IReadOnlyDictionary ExplicitDeletes { get; } internal PlayerKVDiagnosticSnapshot(KVSnapshot source) { Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline; AuthenticationInvalid = source.AuthenticationInvalid; Baseline = ReadOnly(source.Baseline); LocalOnly = ReadOnly(source.LocalOnly); Pending = ReadOnly(source.Pending); ConflictObservations = ReadOnly(source.Conflicts); ActiveBatch = source.Active; UnknownBatch = source.Unknown; LocalData = ReadOnly(source.LocalData); LocalUpdateTicks = ReadOnly(source.LocalUpdateTicks); OrdinaryBuffers = Array.AsReadOnly(source.OrdinaryBuffers.Select(bucket => ReadOnly(bucket)).ToArray()); PreparedOrdinaryBatches = Array.AsReadOnly(source.PreparedOrdinaryBatches.ToArray()); OrdinaryUnknownBatches = Array.AsReadOnly(source.OrdinaryUnknownBatches.ToArray()); LegacyPending = ReadOnly(source.LegacyPending); ExplicitDeletes = ReadOnly(source.ExplicitDeletes); } static IReadOnlyDictionary 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 LocalData = new Dictionary(StringComparer.Ordinal); internal Dictionary LocalUpdateTicks = new Dictionary(StringComparer.Ordinal); // A local origin has no second copy of its value. Ordinary origins can outlive a sent batch. internal Dictionary LocalOrigins = new Dictionary(StringComparer.Ordinal); internal HashSet ImmediatePreservedKeys = new HashSet(StringComparer.Ordinal); // Only v1/v2 conversion can populate this lower layer; the first successful Get removes it. internal Dictionary LegacyLocalOnlyUnderImmediate = new Dictionary(StringComparer.Ordinal); internal List> OrdinaryBuffers = Enumerable.Range(0, 5) .Select(_ => new Dictionary(StringComparer.Ordinal)).ToList(); internal List PreparedOrdinaryBatches = new List(); internal List OrdinaryUnknownBatches = new List(); internal Dictionary LegacyPending = new Dictionary(StringComparer.Ordinal); internal Dictionary ExplicitDeletes = new Dictionary(StringComparer.Ordinal); internal Dictionary Conflicts = new Dictionary(StringComparer.Ordinal); internal Dictionary RemoteOnlyWrites = new Dictionary(); internal PlayerKVBatch Active, Unknown; // Compatibility diagnostics only. A key projection loses cross-bucket sequence history. internal Dictionary Pending => Unsent().GroupBy(item => item.Key, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.OrderBy(item => item.Sequence).Last(), StringComparer.Ordinal); internal Dictionary LocalOnly => LocalOrigins.Where(pair => pair.Value.LocalOnly && LocalData.ContainsKey(pair.Key)) .ToDictionary(pair => pair.Key, pair => LocalData[pair.Key], StringComparer.Ordinal); internal IEnumerable Unsent() { return OrdinaryBuffers.SelectMany(bucket => bucket.Values).Concat(LegacyPending.Values).Concat(ExplicitDeletes.Values); } internal IEnumerable OrdinaryOutstanding() { return OrdinaryBuffers.SelectMany(bucket => bucket.Values) .Concat(PreparedOrdinaryBatches.SelectMany(batch => batch.Items)) .Concat(Active != null && Active.IsOrdinary ? Active.Items : Enumerable.Empty()); } internal KVSnapshot Clone() { return new KVSnapshot { Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid, Baseline = new Dictionary(Baseline, StringComparer.Ordinal), LocalData = new Dictionary(LocalData, StringComparer.Ordinal), LocalUpdateTicks = new Dictionary(LocalUpdateTicks, StringComparer.Ordinal), LocalOrigins = new Dictionary(LocalOrigins, StringComparer.Ordinal), ImmediatePreservedKeys = new HashSet(ImmediatePreservedKeys, StringComparer.Ordinal), LegacyLocalOnlyUnderImmediate = new Dictionary(LegacyLocalOnlyUnderImmediate, StringComparer.Ordinal), OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary(bucket, StringComparer.Ordinal)).ToList(), PreparedOrdinaryBatches = new List(PreparedOrdinaryBatches), OrdinaryUnknownBatches = new List(OrdinaryUnknownBatches), LegacyPending = new Dictionary(LegacyPending, StringComparer.Ordinal), ExplicitDeletes = new Dictionary(ExplicitDeletes, StringComparer.Ordinal), Conflicts = new Dictionary(Conflicts, StringComparer.Ordinal), Active = Active, Unknown = Unknown, RemoteOnlyWrites = new Dictionary(RemoteOnlyWrites) }; } internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; internal string Encode(KVIdentity identity) { var conflicts = new JObject(); foreach (var pair in Conflicts) { conflicts.Add(pair.Key, ValueJson(pair.Value)); } var origins = new JObject(); foreach (var pair in LocalOrigins) { origins.Add(pair.Key, new JObject { ["localOnly"] = pair.Value.LocalOnly, ["sequence"] = pair.Value.Sequence }); } return new JObject { ["formatVersion"] = 3, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId, ["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline, ["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline), ["localData"] = JObject.FromObject(LocalData), ["localUpdateTicks"] = JObject.FromObject(LocalUpdateTicks), ["localOrigins"] = origins, ["immediatePreservedKeys"] = new JArray(ImmediatePreservedKeys), ["legacyLocalOnlyUnderImmediate"] = JObject.FromObject(LegacyLocalOnlyUnderImmediate), ["ordinaryBuffers"] = new JArray(OrdinaryBuffers.Select(bucket => (JToken)ChangesJson(bucket.Values))), ["preparedOrdinaryBatches"] = new JArray(PreparedOrdinaryBatches.Select(BatchJson)), ["ordinaryUnknownBatches"] = new JArray(OrdinaryUnknownBatches.Select(BatchJson)), ["legacyPending"] = ChangesJson(LegacyPending.Values), ["explicitDeletes"] = ChangesJson(ExplicitDeletes.Values), ["conflictObservations"] = conflicts, ["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown), ["remoteOnlyWrites"] = ChangesJson(RemoteOnlyWrites.Values) }.ToString(Formatting.None); } private static JArray ChangesJson(IEnumerable values) { return new JArray(values.OrderBy(item => item.Sequence).Select(ChangeJson)); } private static JObject ValueJson(PlayerKVValue value) { return new JObject { ["exists"] = value.Exists, ["value"] = value.Value }; } private static JObject ChangeJson(PlayerKVChange item) { return new JObject { ["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) }; } private static JToken BatchJson(PlayerKVBatch batch) { if (batch == null) { return JValue.CreateNull(); } return new JObject { ["id"] = batch.Id, ["ordinary"] = batch.IsOrdinary, ["items"] = ChangesJson(batch.Items) }; } internal static KVSnapshot Decode(string text, KVIdentity identity) { JObject root; using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) { root = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); if (reader.Read()) { throw Invalid(); } } var version = Integer(root, "formatVersion"); if (version < 1 || version > 3 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId) { throw Invalid(); } var result = new KVSnapshot { Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"), HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"), Baseline = Strings(Field(root, "baseline", JTokenType.Object)), Active = Batch(root, "activeBatch", version == 3), Unknown = Batch(root, "unknownBatch", version == 3) }; foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name)) { throw Invalid(); } result.Conflicts.Add(property.Name, Value(property.Value)); } if (version >= 2) { foreach (var item in Field(root, "remoteOnlyWrites", JTokenType.Array)) { var change = Change(item); if (!change.Target.Exists || result.RemoteOnlyWrites.ContainsKey(change.Sequence)) { throw Invalid(); } result.RemoteOnlyWrites.Add(change.Sequence, change); } } if (version < 3) { DecodeLegacy(root, version, result); } else { result.LocalData = Strings(Field(root, "localData", JTokenType.Object)); result.LegacyLocalOnlyUnderImmediate = Strings(Field(root, "legacyLocalOnlyUnderImmediate", JTokenType.Object)); foreach (var property in ((JObject)Field(root, "localUpdateTicks", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name) || property.Value.Type != JTokenType.Integer) { throw Invalid(); } result.LocalUpdateTicks.Add(property.Name, (long)property.Value); } foreach (var property in ((JObject)Field(root, "localOrigins", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name)) { throw Invalid(); } result.LocalOrigins.Add(property.Name, (Boolean(property.Value, "localOnly"), Integer(property.Value, "sequence"))); } foreach (var key in Field(root, "immediatePreservedKeys", JTokenType.Array)) { if (key.Type != JTokenType.String || !ValidKey((string)key) || !result.ImmediatePreservedKeys.Add((string)key)) { throw Invalid(); } } result.OrdinaryBuffers = Field(root, "ordinaryBuffers", JTokenType.Array).Select(Changes).ToList(); result.LegacyPending = Changes(Field(root, "legacyPending", JTokenType.Array)); result.ExplicitDeletes = Changes(Field(root, "explicitDeletes", JTokenType.Array)); result.PreparedOrdinaryBatches = ReadBatches(root, "preparedOrdinaryBatches"); result.OrdinaryUnknownBatches = ReadBatches(root, "ordinaryUnknownBatches"); } result.Validate(); return result; } private static Dictionary Changes(JToken array) { if (array.Type != JTokenType.Array) { throw Invalid(); } var values = new Dictionary(StringComparer.Ordinal); foreach (var token in array) { var item = Change(token); if (values.ContainsKey(item.Key)) { throw Invalid(); } values.Add(item.Key, item); } return values; } private static List ReadBatches(JToken root, string name) { var result = new List(); foreach (var item in Field(root, name, JTokenType.Array)) { var wrapper = new JObject { ["batch"] = item.DeepClone() }; var batch = Batch(wrapper, "batch", true); if (batch == null || !batch.IsOrdinary || batch.IsDelete) { throw Invalid(); } result.Add(batch); } return result; } private static void DecodeLegacy(JObject root, long version, KVSnapshot result) { var pending = Changes(Field(root, "pending", JTokenType.Array)); var localOnly = Strings(Field(root, "localOnly", JTokenType.Object)); var overrides = new Dictionary(StringComparer.Ordinal); if (version == 2) { foreach (var property in ((JObject)Field(root, "remoteLocalOverrides", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name)) { throw Invalid(); } overrides.Add(property.Name, Value(property.Value)); } } // Validate the old shape before converting; new format rules must not accept a corrupt old file. if (result.Revision < 1 || result.NextSequence < 1 || (result.Active != null && result.Unknown != null) || (!result.HasBaseline && (result.Baseline.Count != 0 || localOnly.Count != 0 || pending.Count != 0 || result.Active != null || result.Unknown != null || overrides.Count != 0 || result.RemoteOnlyWrites.Count != 0))) { throw Invalid(); } var sequences = new HashSet(); foreach (var item in pending.Values.Concat(result.RemoteOnlyWrites.Values)) { if (item.Sequence < 1 || item.Sequence >= result.NextSequence || !sequences.Add(item.Sequence)) { throw Invalid(); } } if (result.Conflicts.Keys.Any(key => !pending.ContainsKey(key))) { throw Invalid(); } var oldBatch = result.Active ?? result.Unknown; if (oldBatch != null) { foreach (var item in oldBatch.Items) { var hasPending = pending.TryGetValue(item.Key, out var latest); var hasSource = result.RemoteOnlyWrites.Values.Any(source => source.Key == item.Key && source.Sequence > item.Sequence); var same = hasPending && latest.Sequence == item.Sequence && latest.BaseValue.Equals(item.BaseValue) && latest.Target.Equals(item.Target); if (item.Sequence < 1 || item.Sequence >= result.NextSequence || (sequences.Contains(item.Sequence) && !same) || (!hasPending && !hasSource) || (hasPending && (latest.Sequence < item.Sequence || !latest.BaseValue.Equals(item.BaseValue) || (latest.Sequence == item.Sequence && !same)))) { throw Invalid(); } sequences.Add(item.Sequence); } } result.LegacyPending = pending; result.LocalData = new Dictionary(result.Baseline, StringComparer.Ordinal); foreach (var item in pending.Values) { ApplyValue(result.LocalData, item.Key, item.Target); result.LocalOrigins[item.Key] = (false, item.Sequence); } foreach (var pair in localOnly) { result.LocalData[pair.Key] = pair.Value; // Old local-only writes had no stored sequence; zero is permitted only for this converted origin. result.LocalOrigins[pair.Key] = (true, 0); } foreach (var pair in overrides) { ApplyValue(result.LocalData, pair.Key, pair.Value); result.ImmediatePreservedKeys.Add(pair.Key); if (localOnly.TryGetValue(pair.Key, out var lower)) { result.LegacyLocalOnlyUnderImmediate[pair.Key] = lower; } } } private static void ApplyValue(Dictionary values, string key, PlayerKVValue value) { if (value.Exists) { values[key] = value.Value; } else { values.Remove(key); } } internal void Validate() { if (Revision < 1 || NextSequence < 1 || OrdinaryBuffers.Count != 5 || (Active != null && Unknown != null) || (Unknown != null && Unknown.IsOrdinary) || OrdinaryBuffers.Any(bucket => bucket.Count > 10 || bucket.Values.Any(item => !item.Target.Exists)) || ExplicitDeletes.Values.Any(item => item.Target.Exists) || (!HasBaseline && (Baseline.Count != 0 || LocalData.Count != 0 || Unsent().Any() || Active != null || Unknown != null || PreparedOrdinaryBatches.Count != 0 || OrdinaryUnknownBatches.Count != 0 || RemoteOnlyWrites.Count != 0 || LocalOrigins.Count != 0 || LocalUpdateTicks.Count != 0 || ImmediatePreservedKeys.Count != 0 || LegacyLocalOnlyUnderImmediate.Count != 0))) { throw Invalid(); } var sequences = new Dictionary(); void Add(PlayerKVChange item, bool sourceReference = false) { if (!ValidKey(item.Key) || item.Sequence < 1 || item.Sequence >= NextSequence) { throw Invalid(); } if (sequences.TryGetValue(item.Sequence, out var prior)) { if (!sourceReference || prior.Key != item.Key || !prior.BaseValue.Equals(item.BaseValue) || !prior.Target.Equals(item.Target)) { throw Invalid(); } } else { sequences.Add(item.Sequence, item); } } foreach (var item in Unsent().Concat(RemoteOnlyWrites.Values)) { Add(item); } var ids = new HashSet(StringComparer.Ordinal); foreach (var batch in PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Concat(new[] { Active, Unknown }).Where(batch => batch != null)) { if (!Guid.TryParseExact(batch.Id, "N", out _) || !ids.Add(batch.Id) || batch.Items.Count < 1 || batch.Items.Count > 10 || batch.Items.Select(item => item.Key).Distinct(StringComparer.Ordinal).Count() != batch.Items.Count || batch.Items.Any(item => item.Target.Exists != batch.Items[0].Target.Exists) || (batch.IsOrdinary && batch.IsDelete) || !batch.Items.Select(item => item.Sequence).SequenceEqual(batch.Items.Select(item => item.Sequence).OrderBy(value => value))) { throw Invalid(); } foreach (var item in batch.Items) { var reference = !batch.IsOrdinary && (LegacyPending.Values.Concat(ExplicitDeletes.Values) .Any(source => source.Sequence == item.Sequence)); Add(item, reference); } } if (RemoteOnlyWrites.Any(pair => pair.Key != pair.Value.Sequence || !pair.Value.Target.Exists) || PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Any(batch => !batch.IsOrdinary) || LocalOrigins.Any(pair => !ValidKey(pair.Key) || pair.Value.Sequence < 0 || pair.Value.Sequence >= NextSequence || (!pair.Value.LocalOnly && pair.Value.Sequence == 0)) || LocalUpdateTicks.Any(pair => !ValidKey(pair.Key) || pair.Value < 0 || pair.Value > DateTime.MaxValue.Ticks) || ImmediatePreservedKeys.Any(key => !ValidKey(key)) || LegacyLocalOnlyUnderImmediate.Any(pair => !ImmediatePreservedKeys.Contains(pair.Key) || !LocalOrigins.TryGetValue(pair.Key, out var origin) || !origin.LocalOnly) || Conflicts.Keys.Any(key => !Unsent().Any(item => item.Key == key) && !PreparedOrdinaryBatches.Any(batch => batch.Items.Any(item => item.Key == key)) && Unknown == null && OrdinaryUnknownBatches.Count == 0)) { throw Invalid(); } } static Dictionary 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, bool withKind = false) { var node = parent[name]; if (node == null) throw Invalid(); if (node.Type == JTokenType.Null) return null; var id = String(node, "id"); if (!Guid.TryParseExact(id, "N", out _)) throw Invalid(); var items = Field(node, "items", JTokenType.Array).Select(Change).ToArray(); if (items.Length == 0 || items.Length > 10 || items.Select(i => i.Key).Distinct(StringComparer.Ordinal).Count() != items.Length || items.Select(i => i.Sequence).Distinct().Count() != items.Length || items.Any(i => i.Target.Exists != items[0].Target.Exists) || !items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s))) throw Invalid(); return new PlayerKVBatch(id, items, withKind && Boolean(node, "ordinary")); } static JToken Field(JToken parent, string name, JTokenType type) { if (!(parent is JObject obj) || obj[name] == null || obj[name].Type != type) throw Invalid(); return obj[name]; } static string String(JToken parent, string name) => (string)Field(parent, name, JTokenType.String); static long Integer(JToken parent, string name) => (long)Field(parent, name, JTokenType.Integer); static bool Boolean(JToken parent, string name) => (bool)Field(parent, name, JTokenType.Boolean); internal static JsonException Invalid() => new JsonException("Invalid KV cache snapshot."); } }