chore(m2): 保存功能验收及后续清理前检查点

This commit is contained in:
Liubing\LB
2026-10-10 20:40:27 +08:00
parent d3c5394a55
commit 144e7f739f
110 changed files with 12672 additions and 7514 deletions

View File

@@ -65,8 +65,16 @@ namespace game.PlayerDataStorage
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 bool IsOrdinary
{
get;
}
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items, bool ordinary = false)
{
Id = id;
Items = Array.AsReadOnly(items.ToArray());
IsOrdinary = ordinary;
}
}
public sealed class PlayerKVDiagnosticSnapshot
@@ -81,6 +89,34 @@ namespace game.PlayerDataStorage
public IReadOnlyDictionary<string, PlayerKVValue> ConflictObservations { get; }
public PlayerKVBatch ActiveBatch { get; }
public PlayerKVBatch UnknownBatch { get; }
public IReadOnlyDictionary<string, string> LocalData
{
get;
}
public IReadOnlyDictionary<string, long> LocalUpdateTicks
{
get;
}
public IReadOnlyList<IReadOnlyDictionary<string, PlayerKVChange>> OrdinaryBuffers
{
get;
}
public IReadOnlyList<PlayerKVBatch> PreparedOrdinaryBatches
{
get;
}
public IReadOnlyList<PlayerKVBatch> OrdinaryUnknownBatches
{
get;
}
public IReadOnlyDictionary<string, PlayerKVChange> LegacyPending
{
get;
}
public IReadOnlyDictionary<string, PlayerKVChange> ExplicitDeletes
{
get;
}
internal PlayerKVDiagnosticSnapshot(KVSnapshot source)
{
Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline;
@@ -88,6 +124,13 @@ namespace game.PlayerDataStorage
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<string, T> ReadOnly<T>(Dictionary<string, T> source) =>
new ReadOnlyDictionary<string, T>(new Dictionary<string, T>(source, StringComparer.Ordinal));
@@ -118,38 +161,111 @@ namespace game.PlayerDataStorage
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, string> LocalData = new Dictionary<string, string>(StringComparer.Ordinal);
internal Dictionary<string, long> LocalUpdateTicks = new Dictionary<string, long>(StringComparer.Ordinal);
// A local origin has no second copy of its value. Ordinary origins can outlive a sent batch.
internal Dictionary<string, (bool LocalOnly, long Sequence)> LocalOrigins =
new Dictionary<string, (bool LocalOnly, long Sequence)>(StringComparer.Ordinal);
internal HashSet<string> ImmediatePreservedKeys = new HashSet<string>(StringComparer.Ordinal);
// Only v1/v2 conversion can populate this lower layer; the first successful Get removes it.
internal Dictionary<string, string> LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(StringComparer.Ordinal);
internal List<Dictionary<string, PlayerKVChange>> OrdinaryBuffers = Enumerable.Range(0, 5)
.Select(_ => new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal)).ToList();
internal List<PlayerKVBatch> PreparedOrdinaryBatches = new List<PlayerKVBatch>();
internal List<PlayerKVBatch> OrdinaryUnknownBatches = new List<PlayerKVBatch>();
internal Dictionary<string, PlayerKVChange> LegacyPending = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
internal Dictionary<string, PlayerKVChange> ExplicitDeletes = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
internal Dictionary<string, PlayerKVValue> Conflicts = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal);
internal Dictionary<long, PlayerKVChange> RemoteOnlyWrites = new Dictionary<long, PlayerKVChange>();
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
};
// Compatibility diagnostics only. A key projection loses cross-bucket sequence history.
internal Dictionary<string, PlayerKVChange> Pending => Unsent().GroupBy(item => item.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.OrderBy(item => item.Sequence).Last(), StringComparer.Ordinal);
internal Dictionary<string, string> LocalOnly => LocalOrigins.Where(pair => pair.Value.LocalOnly && LocalData.ContainsKey(pair.Key))
.ToDictionary(pair => pair.Key, pair => LocalData[pair.Key], StringComparer.Ordinal);
internal IEnumerable<PlayerKVChange> Unsent()
{
return OrdinaryBuffers.SelectMany(bucket => bucket.Values).Concat(LegacyPending.Values).Concat(ExplicitDeletes.Values);
}
internal IEnumerable<PlayerKVChange> OrdinaryOutstanding()
{
return OrdinaryBuffers.SelectMany(bucket => bucket.Values)
.Concat(PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
.Concat(Active != null && Active.IsOrdinary ? Active.Items : Enumerable.Empty<PlayerKVChange>());
}
internal KVSnapshot Clone()
{
return new KVSnapshot
{
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid,
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal),
LocalData = new Dictionary<string, string>(LocalData, StringComparer.Ordinal),
LocalUpdateTicks = new Dictionary<string, long>(LocalUpdateTicks, StringComparer.Ordinal),
LocalOrigins = new Dictionary<string, (bool LocalOnly, long Sequence)>(LocalOrigins, StringComparer.Ordinal),
ImmediatePreservedKeys = new HashSet<string>(ImmediatePreservedKeys, StringComparer.Ordinal),
LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(LegacyLocalOnlyUnderImmediate, StringComparer.Ordinal),
OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary<string, PlayerKVChange>(bucket, StringComparer.Ordinal)).ToList(),
PreparedOrdinaryBatches = new List<PlayerKVBatch>(PreparedOrdinaryBatches),
OrdinaryUnknownBatches = new List<PlayerKVBatch>(OrdinaryUnknownBatches),
LegacyPending = new Dictionary<string, PlayerKVChange>(LegacyPending, StringComparer.Ordinal),
ExplicitDeletes = new Dictionary<string, PlayerKVChange>(ExplicitDeletes, StringComparer.Ordinal),
Conflicts = new Dictionary<string, PlayerKVValue>(Conflicts, StringComparer.Ordinal),
Active = Active, Unknown = Unknown,
RemoteOnlyWrites = new Dictionary<long, PlayerKVChange>(RemoteOnlyWrites)
};
}
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,
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),
["localOnly"] = JObject.FromObject(LocalOnly), ["pending"] = pending, ["conflictObservations"] = conflicts,
["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown)
["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);
}
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))
};
private static JArray ChangesJson(IEnumerable<PlayerKVChange> 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)
{
@@ -157,48 +273,268 @@ namespace game.PlayerDataStorage
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 (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 {
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)), LocalOnly = Strings(Field(root, "localOnly", JTokenType.Object)),
Active = Batch(root, "activeBatch"), Unknown = Batch(root, "unknownBatch")
Baseline = Strings(Field(root, "baseline", JTokenType.Object)),
Active = Batch(root, "activeBatch", version == 3), Unknown = Batch(root, "unknownBatch", version == 3)
};
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();
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<string, PlayerKVChange> Changes(JToken array)
{
if (array.Type != JTokenType.Array)
{
throw Invalid();
}
var values = new Dictionary<string, PlayerKVChange>(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<PlayerKVBatch> ReadBatches(JToken root, string name)
{
var result = new List<PlayerKVBatch>();
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<string, PlayerKVValue>(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<long>();
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<string, string>(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<string, string> values, string key, PlayerKVValue value)
{
if (value.Exists)
{
values[key] = value.Value;
}
else
{
values.Remove(key);
}
}
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 (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)))
{
if (item.Sequence < 1 || item.Sequence >= NextSequence || sequences.ContainsKey(item.Sequence)) throw Invalid();
sequences.Add(item.Sequence, item.Key);
throw Invalid();
}
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)
var sequences = new Dictionary<long, PlayerKVChange>();
void Add(PlayerKVChange item, bool sourceReference = false)
{
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();
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<string>(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<string, string> Strings(JToken value)
@@ -223,7 +559,7 @@ namespace game.PlayerDataStorage
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)
static PlayerKVBatch Batch(JToken parent, string name, bool withKind = false)
{
var node = parent[name];
if (node == null) throw Invalid();
@@ -234,7 +570,7 @@ namespace game.PlayerDataStorage
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);
return new PlayerKVBatch(id, items, withKind && Boolean(node, "ordinary"));
}
static JToken Field(JToken parent, string name, JTokenType type)
{