250 lines
14 KiB
C#
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.");
|
|
}
|
|
}
|