From 21fd4e07ebe0634b30b9da6aa63d983654cc9b62 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 14:10:13 +0200 Subject: [PATCH] Version hot-body demand generations --- src/GameCult.Mesh/CultMeshBodyDemand.cs | 47 ++++++++++++++++++- .../CultMeshBodyTransportTests.cs | 8 ++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Mesh/CultMeshBodyDemand.cs b/src/GameCult.Mesh/CultMeshBodyDemand.cs index 55fa3807..9c9e17da 100644 --- a/src/GameCult.Mesh/CultMeshBodyDemand.cs +++ b/src/GameCult.Mesh/CultMeshBodyDemand.cs @@ -225,9 +225,11 @@ namespace GameCult.Mesh { internal CultMeshBodyDemandPlan( string bodyId, + long generation, IReadOnlyList consumers) { BodyId = bodyId; + Generation = generation; Consumers = consumers; RequiresSharedMemory = consumers.Any(value => value.Transport == CultMeshBodyTransportKind.SharedMemory); RequiresSharedFileMapping = consumers.Any(value => value.Transport == CultMeshBodyTransportKind.SharedFileMapping); @@ -235,6 +237,11 @@ namespace GameCult.Mesh } public string BodyId { get; } + /// + /// Monotone generation of the exact routed consumer set. Publishers must rotate any + /// retained capability before writing after this value changes. + /// + public long Generation { get; } public bool HasConsumers => Consumers.Count > 0; public bool RequiresSharedMemory { get; } public bool RequiresSharedFileMapping { get; } @@ -271,6 +278,8 @@ namespace GameCult.Mesh private readonly object _gate = new(); private readonly Dictionary _demands = new(StringComparer.Ordinal); + private readonly Dictionary _bodyGenerations = + new(StringComparer.Ordinal); private bool _disposed; public CultMeshBodyDemandTracker(CultNetDatabaseSubscriptionServer subscriptions) @@ -298,7 +307,10 @@ namespace GameCult.Mesh .OrderBy(value => value.ConsumerRuntimeId, StringComparer.Ordinal) .ThenBy(value => value.SubscriptionId, StringComparer.Ordinal) .ToArray(); - return new CultMeshBodyDemandPlan(bodyId, routes); + return new CultMeshBodyDemandPlan( + bodyId, + _bodyGenerations.TryGetValue(bodyId, out var generation) ? generation : 0, + routes); } } @@ -310,6 +322,7 @@ namespace GameCult.Mesh _disposed = true; if (_subscriptions != null) _subscriptions.DemandChanged -= OnDemandChanged; _demands.Clear(); + _bodyGenerations.Clear(); } } @@ -322,11 +335,43 @@ namespace GameCult.Mesh lock (_gate) { if (_disposed) return; + _demands.TryGetValue(key, out var previous); + var affectedBodyIds = (previous?.BodyIds ?? Array.Empty()) + .Concat(demand.BodyIds) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var before = affectedBodyIds.ToDictionary( + bodyId => bodyId, + RoutedConsumerSignature, + StringComparer.Ordinal); if (demand.Active) _demands[key] = demand; else _demands.Remove(key); + foreach (var bodyId in affectedBodyIds) + { + var after = RoutedConsumerSignature(bodyId); + if (string.Equals(before[bodyId], after, StringComparison.Ordinal)) continue; + _bodyGenerations[bodyId] = checked( + (_bodyGenerations.TryGetValue(bodyId, out var generation) ? generation : 0) + 1); + } } } + private string RoutedConsumerSignature(string bodyId) => string.Join( + "\u001e", + _demands.Values + .Where(value => value.BodyIds.Contains(bodyId, StringComparer.Ordinal)) + .Select(Route) + .Where(value => value != null) + .Cast() + .OrderBy(value => value.ConsumerRuntimeId, StringComparer.Ordinal) + .ThenBy(value => value.SubscriptionId, StringComparer.Ordinal) + .Select(value => string.Join( + "\u001f", + value.ConsumerRuntimeId, + value.SubscriptionId, + value.Transport.ToString()))); + private void OnDemandChanged(CultNetDatabaseSubscriptionDemand demand) => Observe(demand); private static CultMeshBodyConsumerRoute? Route(CultNetDatabaseSubscriptionDemand demand) diff --git a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs index e644c3ff..0421aea7 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs @@ -318,7 +318,9 @@ public sealed class CultMeshBodyTransportTests public void BodyDemand_SelectsSharedMemoryLocallyAndNetworkRemotelyWithoutDuplicateFallbackWork() { using var tracker = new CultMeshBodyDemandTracker(); + tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(0); tracker.Observe(Demand("unity-local", "local", sameMachine: true)); + tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(1); tracker.Observe(Demand("unity-remote", "remote", sameMachine: false)); var mixed = tracker.Plan("eve:entity-soa:aetheria.daemon:pilot"); @@ -335,8 +337,14 @@ public sealed class CultMeshBodyTransportTests tracker.Observe(Demand("unity-remote", "remote", sameMachine: false, active: false)); var localOnly = tracker.Plan("eve:entity-soa:aetheria.daemon:pilot"); + localOnly.Generation.Should().Be(3); localOnly.RequiresSharedMemory.Should().BeTrue(); localOnly.RequiresNetwork.Should().BeFalse(); + + tracker.Observe(Demand("unity-local", "local", sameMachine: true, active: false)); + tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(4); + tracker.Observe(Demand("unity-local", "local", sameMachine: true)); + tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(5); } [Test]