From e3e07dfc58b692d3362bc3de6782814226cee4b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:56:08 +0100 Subject: [PATCH] Add CultMesh reactive reconciliation deltas --- src/GameCult.Mesh/CultMeshPrimitives.cs | 134 +++++++++++++++++- .../CultMeshStreamingTests.cs | 5 + 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index b38abd05..fb34c000 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -1156,19 +1157,32 @@ namespace GameCult.Mesh internal CultMeshReactiveDocumentReconciliation( TDocument canonical, TDocument predicted, + IReadOnlyDictionary delta, + int version, DateTimeOffset receivedAt) { Canonical = canonical; Predicted = predicted; + Delta = delta ?? throw new ArgumentNullException(nameof(delta)); + Version = version; ReceivedAt = receivedAt; } + /// Gets the monotonically increasing reconciliation version for this reactive document. + public int Version { get; } + /// Gets the authoritative canonical document snapshot. public TDocument Canonical { get; } /// Gets the locally predicted document snapshot that was active when the canonical value arrived. public TDocument Predicted { get; } + /// + /// Gets the predicted-vs-canonical member delta. Numeric members store predicted minus canonical; + /// non-numeric members store the predicted value. + /// + public IReadOnlyDictionary Delta { get; } + /// Gets when the canonical snapshot was received. public DateTimeOffset ReceivedAt { get; } } @@ -1190,6 +1204,7 @@ namespace GameCult.Mesh private bool _flushQueued; private bool _flushing; private bool _disposed; + private int _reconciliationVersion; internal CultMeshReactiveDocument( CultMeshDocumentHandle document, @@ -1367,10 +1382,22 @@ namespace GameCult.Mesh var nextCanonical = CultMeshDocumentHandle.CloneDocument(canonical); if (_dirty || _flushing) { - Reconciliation = new CultMeshReactiveDocumentReconciliation( - nextCanonical, - CultMeshDocumentHandle.CloneDocument(Current), - DateTimeOffset.UtcNow); + var predicted = CultMeshDocumentHandle.CloneDocument(Current); + var delta = CreateReconciliationDelta(predicted, nextCanonical); + if (delta.Count == 0) + { + Reconciliation = null; + } + else + { + Reconciliation = new CultMeshReactiveDocumentReconciliation( + nextCanonical, + predicted, + delta, + ++_reconciliationVersion, + DateTimeOffset.UtcNow); + } + if (!_options.ReplaceDirtyCurrentOnCanonicalSnapshot) return; } @@ -1420,11 +1447,110 @@ namespace GameCult.Mesh return CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)); } + private static IReadOnlyDictionary CreateReconciliationDelta( + TDocument predicted, + TDocument canonical) + { + var delta = new Dictionary(StringComparer.Ordinal); + foreach (var member in GetComparableMembers(typeof(TDocument))) + { + var predictedValue = member.GetValue(predicted); + var canonicalValue = member.GetValue(canonical); + if (CultMeshValuesEqual(predictedValue, canonicalValue)) + continue; + + delta[member.Name] = TryCreateNumericDelta(predictedValue, canonicalValue, out var numericDelta) + ? numericDelta + : predictedValue; + } + + return delta; + } + + private static IEnumerable GetComparableMembers(Type documentType) + { + foreach (var property in documentType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetMethod == null || property.GetIndexParameters().Length != 0) + continue; + yield return new CultMeshComparableMember(property.Name, property.GetValue); + } + + foreach (var field in documentType.GetFields(BindingFlags.Instance | BindingFlags.Public)) + { + yield return new CultMeshComparableMember(field.Name, field.GetValue); + } + } + + private static bool TryCreateNumericDelta(object? predicted, object? canonical, out object? delta) + { + delta = null; + if (predicted == null || canonical == null) + return false; + var predictedType = Nullable.GetUnderlyingType(predicted.GetType()) ?? predicted.GetType(); + var canonicalType = Nullable.GetUnderlyingType(canonical.GetType()) ?? canonical.GetType(); + if (!IsNumericType(predictedType) || !IsNumericType(canonicalType)) + return false; + + delta = Convert.ToDouble(predicted, CultureInfo.InvariantCulture) - + Convert.ToDouble(canonical, CultureInfo.InvariantCulture); + return true; + } + + private static bool IsNumericType(Type type) + { + if (type.IsEnum) + return false; + return Type.GetTypeCode(type) switch + { + TypeCode.Byte or + TypeCode.SByte or + TypeCode.UInt16 or + TypeCode.UInt32 or + TypeCode.UInt64 or + TypeCode.Int16 or + TypeCode.Int32 or + TypeCode.Int64 or + TypeCode.Decimal or + TypeCode.Double or + TypeCode.Single => true, + _ => false + }; + } + + private static bool CultMeshValuesEqual(object? left, object? right) + { + if (ReferenceEquals(left, right)) + return true; + if (left == null || right == null) + return false; + if (left.Equals(right)) + return true; + if (left is string || right is string) + return false; + if (left is IEnumerable leftEnumerable && right is IEnumerable rightEnumerable) + return leftEnumerable.Cast().SequenceEqual(rightEnumerable.Cast()); + return false; + } + private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(CultMeshReactiveDocument)); } + + private readonly struct CultMeshComparableMember + { + public CultMeshComparableMember(string name, Func getValue) + { + Name = name; + GetValue = getValue; + } + + public string Name { get; } + + public Func GetValue { get; } + } } /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 7585ce8f..4a062e75 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -994,8 +994,13 @@ public sealed class CultMeshStreamingTests reactive.Current.Text.Should().Be("local-prediction"); reactive.Reconciliation.Should().NotBeNull(); + reactive.Reconciliation!.Version.Should().Be(1); reactive.Reconciliation!.Canonical.Text.Should().Be("canonical-correction"); reactive.Reconciliation.Predicted.Text.Should().Be("local-prediction"); + reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Text)); + reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Text)].Should().Be("local-prediction"); + reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Revision)); + reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Revision)].Should().Be(-5.0); } [Test]