From 0dee5246628268a2eb476a38d0dd7c72e824792d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 20:45:25 +0100 Subject: [PATCH 01/16] Route EveUnity provider state through CultMesh sessions --- .../EveUnityCultMeshConnectionContext.cs | 84 +++++++++++++++++++ .../EveUnityCultMeshConnectionContext.cs.meta | 2 + .../EveUnityCultMeshLiveProviderTransport.cs | 49 ++++++++--- .../EveUnityCultMeshPlayableWorldProvider.cs | 39 +++++++-- .../EveUnityCultMeshProviderDiscovery.cs | 52 ++++++++---- 5 files changed, 195 insertions(+), 31 deletions(-) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs new file mode 100644 index 0000000..d6f7abf --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Mesh; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityCultMeshConnectionContext : IDisposable + { + private readonly CultMeshDiscoveryService _discovery; + + internal EveUnityCultMeshConnectionContext( + CultMeshEndpointId endpointId, + CultMeshDiscoveryService discovery, + CultMeshSessionManager sessions) + { + EndpointId = endpointId ?? throw new ArgumentNullException(nameof(endpointId)); + _discovery = discovery ?? throw new ArgumentNullException(nameof(discovery)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + } + + public CultMeshEndpointId EndpointId { get; } + public CultMeshSessionManager Sessions { get; } + + public Task OpenDocumentsAsync(CancellationToken cancellationToken = default) => + Sessions.ConnectAsync(EndpointId, CultMeshProtocols.Documents, cancellationToken); + + public void Dispose() + { + Sessions.Dispose(); + _discovery.Dispose(); + } + + internal static EveUnityCultMeshConnectionContext Create( + string endpointId, + IEnumerable routes) + { + var identity = CultMeshEndpointId.Parse(endpointId); + var discovery = new CultMeshDiscoveryService(new[] { new FixedRouteSource(routes) }); + return new EveUnityCultMeshConnectionContext( + identity, + discovery, + new CultMeshSessionManager(discovery, new ICultMeshTransportConnector[] + { + new CultMeshSchemaTransportConnector() + })); + } + + private sealed class FixedRouteSource : ICultMeshLookupSource + { + private readonly CultMeshVerseDescriptor[] _routes; + + public FixedRouteSource(IEnumerable routes) + { + _routes = routes?.ToArray() ?? throw new ArgumentNullException(nameof(routes)); + } + + public string SourceId => "eve-unity-rendezvous"; + + public Task> LookupAsync( + CultMeshDiscoveryQuery query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var now = DateTimeOffset.UtcNow; + IReadOnlyList observations = _routes + .Where(route => string.Equals(route.VerseId, query.EndpointId, StringComparison.Ordinal)) + .Select(route => new CultMeshDiscoveryObservation( + route, + SourceId, + now, + now.AddMinutes(5), + CultMeshDiscoveryTrust.Unsigned, + "CultMesh rendezvous catalog")) + .ToArray(); + return Task.FromResult(observations); + } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta new file mode 100644 index 0000000..7d74b1b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e2ece888321b9854fb4424a65448f703 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 566b19e..0cae7e9 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -39,6 +39,8 @@ namespace GameCult.Eve.UnityScene private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; + private readonly EveUnityCultMeshConnectionContext? _connection; + private CultMeshSession? _documentSession; private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); private readonly Dictionary _prefabs = new Dictionary(StringComparer.Ordinal); @@ -59,7 +61,8 @@ namespace GameCult.Eve.UnityScene string endpoint, string providerId, string surfaceId, - string runtimeId = "eve-unity") + string runtimeId = "eve-unity", + EveUnityCultMeshConnectionContext? connection = null) { _replicaPath = string.IsNullOrWhiteSpace(replicaPath) ? throw new ArgumentException("Replica path must be non-empty.", nameof(replicaPath)) @@ -74,6 +77,7 @@ namespace GameCult.Eve.UnityScene ? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId)) : surfaceId.Trim(); _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); + _connection = connection; CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); } @@ -171,6 +175,7 @@ namespace GameCult.Eve.UnityScene _node?.Dispose(); _snapshot?.Dispose(); _subscriptions?.Dispose(); + _connection?.Dispose(); _node = null; _snapshot = null; _subscriptions = null; @@ -235,6 +240,26 @@ namespace GameCult.Eve.UnityScene }) .GetAwaiter() .GetResult(); + if (_connection != null) + { + _documentSession = _connection.OpenDocumentsAsync().GetAwaiter().GetResult(); + _snapshot = CultMeshSnapshotSession.ConnectAsync( + _connection.Sessions, + _connection.EndpointId, + new CultMeshSnapshotRequestOptions + { + ShardId = RemoteShardId, + ShardEpoch = 1, + ConnectTimeout = TimeSpan.FromSeconds(5), + ResponseTimeout = TimeSpan.FromSeconds(10), + MessageIdPrefix = "eve-unity", + RudpRuntimeId = _runtimeId, + RudpMaxFragmentBytes = 1024 + }, + _networkRegistry) + .GetAwaiter().GetResult(); + return; + } _snapshot = CultMesh.SnapshotSession( _endpoint, new CultMeshSnapshotRequestOptions @@ -303,16 +328,20 @@ namespace GameCult.Eve.UnityScene private void EnsureLiveSubscriptions() { if (_subscriptions != null) return; - var endpoint = new Uri(_endpoint); - var client = CultNetSchemaClients.CreateForEndpoint(_endpoint); - client.Connect(endpoint.Host, endpoint.Port); - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); - while (!client.Connected && DateTime.UtcNow < deadline) - System.Threading.Thread.Sleep(1); - if (!client.Connected) + var client = _documentSession?.OpenSchemaClient() + ?? CultNetSchemaClients.CreateForEndpoint(_endpoint); + if (_documentSession == null) { - client.Dispose(); - throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'."); + var endpoint = new Uri(_endpoint); + client.Connect(endpoint.Host, endpoint.Port); + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (!client.Connected && DateTime.UtcNow < deadline) + System.Threading.Thread.Sleep(1); + if (!client.Connected) + { + client.Dispose(); + throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'."); + } } _subscriptions = new CultNetDatabaseSubscriptionClient(client, _node!.Database.Cache, _networkRegistry!); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index ac39266..884c818 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Collections.Concurrent; +using System.Threading.Tasks; using GameCult.Eve.Surface; using UnityEngine; @@ -29,6 +30,7 @@ namespace GameCult.Eve.UnityScene private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; + private Task? _discovery; private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); public EveEntitySoaViewDocument? CurrentEntityView { get; private set; } @@ -117,15 +119,20 @@ namespace GameCult.Eve.UnityScene private void OnDestroy() { + var transportOwnedConnection = _transport != null; _bridge?.Dispose(); _transport?.Dispose(); _bridge = null; _transport = null; Selection = null; + if (!transportOwnedConnection && _discovery?.Status == TaskStatus.RanToCompletion) + _discovery.Result.Connection.Dispose(); + _discovery = null; } private void Update() { + CompleteDiscoveryOnMainThread(); _transport?.PumpLiveEvents(); EveEntitySoaViewDocument? latest = null; while (_pendingEntityViews.TryDequeue(out var next)) latest = next; @@ -150,12 +157,29 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - Selection = new EveUnityCultMeshProviderDiscovery().Discover( - rendezvousEndpoint, - providerFilter, - surfaceFilter, - surfaceKind, - verseFilter); + if (_discovery == null) + _discovery = new EveUnityCultMeshProviderDiscovery().DiscoverAsync( + rendezvousEndpoint, + providerFilter, + surfaceFilter, + surfaceKind, + verseFilter); + CompleteDiscoveryOnMainThread(); + if (_transport == null) + throw new InvalidOperationException("EveUnity CultMesh provider discovery is still in progress."); + } + + private void CompleteDiscoveryOnMainThread() + { + if (_transport != null || _discovery == null || !_discovery.IsCompleted) + return; + if (_discovery.IsFaulted) + throw _discovery.Exception?.GetBaseException() + ?? new InvalidOperationException("EveUnity CultMesh provider discovery failed."); + if (_discovery.IsCanceled) + throw new InvalidOperationException("EveUnity CultMesh provider discovery was cancelled."); + + Selection = _discovery.Result; var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath) ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc") : replicaPath; @@ -164,7 +188,8 @@ namespace GameCult.Eve.UnityScene Selection.Endpoint, Selection.ProviderId, Selection.SurfaceId, - runtimeId); + runtimeId, + Selection.Connection); _transport.EntityViewAvailable += view => _pendingEntityViews.Enqueue(view); _bridge = new EveUnitySceneLiveProviderBridge(_transport); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index b2941d7..cb6a641 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Mesh; @@ -17,13 +18,15 @@ namespace GameCult.Eve.UnityScene string verseId, string providerId, string surfaceId, - string surfaceKind) + string surfaceKind, + EveUnityCultMeshConnectionContext connection) { Endpoint = endpoint ?? ""; VerseId = verseId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); } public string Endpoint { get; } @@ -31,6 +34,7 @@ namespace GameCult.Eve.UnityScene public string ProviderId { get; } public string SurfaceId { get; } public string SurfaceKind { get; } + public EveUnityCultMeshConnectionContext Connection { get; } } public sealed class EveUnityCultMeshProviderDiscovery @@ -46,17 +50,28 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string surfaceKind = "interactive-world", string verseId = "") + => DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId) + .GetAwaiter().GetResult(); + + public async Task DiscoverAsync( + string rendezvousEndpoint, + string providerId = "", + string surfaceId = "", + string surfaceKind = "interactive-world", + string verseId = "", + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); - var response = RunNetwork(() => CultMesh.CreateVerseDiscoveryClient().FetchAsync( + var response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync( rendezvousEndpoint, new CultMeshVerseCatalogRequestMessage { VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId }, TransportVersion = "cultmesh.v0" - })); + }).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); var candidates = response.Verses .Where(verse => string.IsNullOrWhiteSpace(verseId) || @@ -71,9 +86,13 @@ namespace GameCult.Eve.UnityScene var failures = new List(); foreach (var candidate in candidates) { + EveUnityCultMeshConnectionContext? connection = null; try { - var advertisement = FetchAdvertisements(candidate.Endpoint) + connection = EveUnityCultMeshConnectionContext.Create( + candidate.Verse.VerseId, + new[] { candidate.Verse.ToVerseDescriptor() }); + var advertisement = (await FetchAdvertisementsAsync(connection, cancellationToken).ConfigureAwait(false)) .Where(document => string.IsNullOrWhiteSpace(providerId) || string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) .Select(document => new @@ -94,10 +113,12 @@ namespace GameCult.Eve.UnityScene candidate.Verse.VerseId, advertisement.Document.ProviderId, advertisement.Surface.SurfaceId, - advertisement.Surface.SurfaceKind); + advertisement.Surface.SurfaceKind, + connection); } catch (Exception error) { + connection?.Dispose(); failures.Add($"{candidate.Endpoint}: {error.Message}"); } } @@ -107,12 +128,15 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}"); } - private static EveProviderAdvertisementDocument[] FetchAdvertisements(string endpoint) + private static async Task FetchAdvertisementsAsync( + EveUnityCultMeshConnectionContext connection, + CancellationToken cancellationToken) { var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes); var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry); - var snapshot = CultMesh.SnapshotEndpoint( - endpoint, + using var snapshot = await CultMeshSnapshotSession.ConnectAsync( + connection.Sessions, + connection.EndpointId, new CultMeshSnapshotEndpointOptions { Context = CultMesh.Verse("eve.discovery", "eve-unity").Context, @@ -127,12 +151,12 @@ namespace GameCult.Eve.UnityScene RudpRuntimeId = "eve-unity.discovery", RudpMaxFragmentBytes = 1024 } - }); - return RunNetwork(() => snapshot.FetchDocumentsAsync()) - .ToArray(); + }.Request, + networkRegistry, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return (await snapshot.FetchDocumentsAsync() + .ConfigureAwait(false)).ToArray(); } - - private static T RunNetwork(Func> operation) => - Task.Run(operation).GetAwaiter().GetResult(); } } From 10ba7e5b07548bdf76ff74c14f4dc8db50446270 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 21:15:04 +0100 Subject: [PATCH 02/16] Select live Eve surfaces by runtime intent --- .../Runtime/EveCultMeshSurfaceSelection.cs | 92 +++++++++++++++++++ .../EveCultMeshSurfaceSelection.cs.meta | 2 + .../EveCultMeshSurfaceSelectionTests.cs | 79 ++++++++++++++++ .../EveCultMeshSurfaceSelectionTests.cs.meta | 2 + 4 files changed, 175 insertions(+) create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs.meta diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs new file mode 100644 index 0000000..f4625d2 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Mesh; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + public sealed class EveSurfaceRequest + { + public string ProviderId { get; set; } = ""; + public string SurfaceId { get; set; } = ""; + public string SurfaceKind { get; set; } = ""; + public string LoweringTarget { get; set; } = ""; + public bool RequireActive { get; set; } = true; + } + + public sealed class EveSurfaceSelection + { + internal EveSurfaceSelection( + EveProviderAdvertisementDocument provider, + EveAdvertisedSurface advertisement, + CultMeshDocumentHandle surface) + { + Provider = provider; + Advertisement = advertisement; + Surface = surface; + } + + public EveProviderAdvertisementDocument Provider { get; } + public EveAdvertisedSurface Advertisement { get; } + public CultMeshDocumentHandle Surface { get; } + } + + public static class EveCultMeshSurfaceSelection + { + public static async Task SurfaceAsync( + this CultMeshClient mesh, + string endpointId, + EveSurfaceRequest? request = null, + CancellationToken cancellationToken = default) + { + if (mesh == null) throw new ArgumentNullException(nameof(mesh)); + var resolved = request ?? new EveSurfaceRequest(); + var providers = await mesh.CollectionAsync(endpointId, cancellationToken) + .ConfigureAwait(false); + var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved); + var surface = await mesh.DocumentAsync( + endpointId, + match.Surface.RecordRef, + cancellationToken) + .ConfigureAwait(false); + return new EveSurfaceSelection(match.Provider, match.Surface, surface); + } + + public static (EveProviderAdvertisementDocument Provider, EveAdvertisedSurface Surface) Select( + IEnumerable providers, + EveSurfaceRequest request) + { + if (providers == null) throw new ArgumentNullException(nameof(providers)); + if (request == null) throw new ArgumentNullException(nameof(request)); + var match = providers + .Where(provider => Matches(request.ProviderId, provider.ProviderId)) + .SelectMany(provider => provider.Surfaces.Select(surface => new { Provider = provider, Surface = surface })) + .Where(candidate => Matches(request.SurfaceId, candidate.Surface.SurfaceId)) + .Where(candidate => Matches(request.SurfaceKind, candidate.Surface.SurfaceKind)) + .Where(candidate => !request.RequireActive || string.Equals(candidate.Surface.Status, "active", StringComparison.OrdinalIgnoreCase)) + .Where(candidate => string.IsNullOrWhiteSpace(request.LoweringTarget) || + candidate.Surface.WorldInteraction?.LoweringTargets.Any(target => + string.Equals(target, request.LoweringTarget, StringComparison.Ordinal)) == true) + .OrderBy(candidate => candidate.Provider.ProviderId, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Surface.SurfaceId, StringComparer.Ordinal) + .FirstOrDefault(); + if (match == null) + throw new InvalidOperationException( + $"No advertised Eve surface matched provider='{request.ProviderId}', surface='{request.SurfaceId}', " + + $"kind='{request.SurfaceKind}', loweringTarget='{request.LoweringTarget}'."); + if (!string.Equals(match.Surface.Transport, "cultmesh-record", StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Eve surface '{match.Surface.SurfaceId}' uses unsupported transport '{match.Surface.Transport}'."); + if (string.IsNullOrWhiteSpace(match.Surface.RecordRef)) + throw new InvalidOperationException($"Eve surface '{match.Surface.SurfaceId}' does not advertise a record reference."); + return (match.Provider, match.Surface); + } + + private static bool Matches(string expected, string actual) => + string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs.meta new file mode 100644 index 0000000..5385db9 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3c40df8a2a14d7db2f45f7018591fd3 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs new file mode 100644 index 0000000..280dd24 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs @@ -0,0 +1,79 @@ +using System; +using GameCult.Eve.Surface; +using NUnit.Framework; + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveCultMeshSurfaceSelectionTests + { + [Test] + public void Select_MatchesDeveloperIntentWithoutPhysicalTopology() + { + var provider = Provider( + new EveAdvertisedSurface( + "aetheria.pilot", + EveSurfaceDocument.SchemaId, + "eve:surface:aetheria.pilot", + "cultmesh-record", + "active", + "interactive-world", + new EveWorldInteractionAdvertisement( + "world3d", + Array.Empty(), + "provider-command", + "eve:commands", + EveCommandReceiptDocument.SchemaId, + "eve:receipts", + "eve:assets", + new[] { "unity-scene", "web" }, + "provider"))); + + var selected = EveCultMeshSurfaceSelection.Select( + new[] { provider }, + new EveSurfaceRequest + { + ProviderId = "aetheria.daemon", + SurfaceKind = "interactive-world", + LoweringTarget = "unity-scene" + }); + + Assert.That(selected.Provider, Is.SameAs(provider)); + Assert.That(selected.Surface.SurfaceId, Is.EqualTo("aetheria.pilot")); + Assert.That(selected.Surface.RecordRef, Is.EqualTo("eve:surface:aetheria.pilot")); + } + + [Test] + public void Select_RejectsUnsupportedLoweringTarget() + { + var provider = Provider(new EveAdvertisedSurface( + "aetheria.pilot", + EveSurfaceDocument.SchemaId, + "eve:surface:aetheria.pilot", + "cultmesh-record", + "active", + "interactive-world", + new EveWorldInteractionAdvertisement( + "world3d", Array.Empty(), "", "", "", "", "", + new[] { "unity-scene" }, "provider"))); + + Assert.Throws(() => EveCultMeshSurfaceSelection.Select( + new[] { provider }, + new EveSurfaceRequest { LoweringTarget = "flutter" })); + } + + private static EveProviderAdvertisementDocument Provider(EveAdvertisedSurface surface) => + new EveProviderAdvertisementDocument( + "aetheria.daemon", + "aetheria", + "aetheria", + "Aetheria", + "game", + "aetheria.daemon", + "2026-07-12T00:00:00Z", + new EveProviderFreshness("fresh", "2026-07-12T00:00:00Z", 5000), + Array.Empty(), + Array.Empty(), + new[] { surface }, + Array.Empty()); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs.meta new file mode 100644 index 0000000..c22c711 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c578fb24db4b41fc88ea08bde4c42dd4 From 5118387a44605155aa80a4de17b7fedffd7c95e0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 21:19:25 +0100 Subject: [PATCH 03/16] Resolve Eve plugin deployments from advertisements --- .../Runtime/EveCultMeshSurfaceSelection.cs | 49 ++++++- .../Runtime/EvePluginAdvertisementDocument.cs | 125 ++++++++++++++++++ .../EvePluginAdvertisementDocument.cs.meta | 2 + .../Runtime/EveProviderDocuments.cs | 30 ++++- .../EveCultMeshSurfaceSelectionTests.cs | 56 ++++++++ 5 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs index f4625d2..9c6f82f 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs @@ -23,16 +23,19 @@ namespace GameCult.Eve.Surface internal EveSurfaceSelection( EveProviderAdvertisementDocument provider, EveAdvertisedSurface advertisement, - CultMeshDocumentHandle surface) + CultMeshDocumentHandle surface, + IReadOnlyList plugins) { Provider = provider; Advertisement = advertisement; Surface = surface; + Plugins = plugins; } public EveProviderAdvertisementDocument Provider { get; } public EveAdvertisedSurface Advertisement { get; } public CultMeshDocumentHandle Surface { get; } + public IReadOnlyList Plugins { get; } } public static class EveCultMeshSurfaceSelection @@ -48,12 +51,54 @@ namespace GameCult.Eve.Surface var providers = await mesh.CollectionAsync(endpointId, cancellationToken) .ConfigureAwait(false); var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved); + IReadOnlyList plugins = Array.Empty(); + if (match.Surface.RequiresPlugins.Count > 0) + { + var advertisements = await mesh.CollectionAsync(endpointId, cancellationToken) + .ConfigureAwait(false); + plugins = ResolvePlugins(match.Surface, await advertisements.LatestAsync().ConfigureAwait(false)); + } var surface = await mesh.DocumentAsync( endpointId, match.Surface.RecordRef, cancellationToken) .ConfigureAwait(false); - return new EveSurfaceSelection(match.Provider, match.Surface, surface); + return new EveSurfaceSelection(match.Provider, match.Surface, surface, plugins); + } + + public static IReadOnlyList ResolvePlugins( + EveAdvertisedSurface surface, + IEnumerable advertisements) + { + if (surface == null) throw new ArgumentNullException(nameof(surface)); + if (advertisements == null) throw new ArgumentNullException(nameof(advertisements)); + var available = advertisements + .GroupBy(plugin => plugin.PluginId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.OrderByDescending(plugin => plugin.Version, StringComparer.Ordinal).First(), StringComparer.Ordinal); + var selected = new List(); + foreach (var requirement in surface.RequiresPlugins) + { + if (!available.TryGetValue(requirement.PluginId, out var plugin)) + { + if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Eve surface '{surface.SurfaceId}' requires unavailable plugin '{requirement.PluginId}'."); + continue; + } + + var claims = plugin.Schemas.Concat(plugin.ComponentKinds).Concat(plugin.Commands) + .ToHashSet(StringComparer.Ordinal); + var missing = requirement.RequiredCapabilities.Where(capability => !claims.Contains(capability)).ToArray(); + if (missing.Length > 0) + { + if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Eve plugin '{plugin.PluginId}' does not advertise required capabilities: {string.Join(", ", missing)}."); + continue; + } + selected.Add(plugin); + } + return selected; } public static (EveProviderAdvertisementDocument Provider, EveAdvertisedSurface Surface) Select( diff --git a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs new file mode 100644 index 0000000..d213976 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using MessagePack; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.plugin_advertisement", SchemaId)] + [MessagePackObject] + public sealed class EvePluginAdvertisementDocument + { + public const string SchemaId = "gamecult.eve.plugin_advertisement.v1"; + + public EvePluginAdvertisementDocument( + string pluginId, + string ownerService, + string version, + string manifestAddress, + EvePluginRuntimeAdvertisement runtime, + IReadOnlyList schemas, + IReadOnlyList componentKinds, + IReadOnlyList commands, + IReadOnlyList fixtures) + : this(SchemaId, pluginId, ownerService, version, manifestAddress, runtime, schemas, componentKinds, commands, fixtures) + { + } + + [SerializationConstructor] + public EvePluginAdvertisementDocument( + string schema, + string pluginId, + string ownerService, + string version, + string manifestAddress, + EvePluginRuntimeAdvertisement runtime, + IReadOnlyList schemas, + IReadOnlyList componentKinds, + IReadOnlyList commands, + IReadOnlyList fixtures) + { + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + PluginId = pluginId ?? ""; + OwnerService = ownerService ?? ""; + Version = version ?? ""; + ManifestAddress = manifestAddress ?? ""; + Runtime = runtime ?? new EvePluginRuntimeAdvertisement("", "", Array.Empty(), Array.Empty(), null); + Schemas = schemas ?? Array.Empty(); + ComponentKinds = componentKinds ?? Array.Empty(); + Commands = commands ?? Array.Empty(); + Fixtures = fixtures ?? Array.Empty(); + } + + [Key(0)] public string Schema { get; } + [Key(1), CultName] public string PluginId { get; } + [Key(2)] public string OwnerService { get; } + [Key(3)] public string Version { get; } + [Key(4)] public string ManifestAddress { get; } + [Key(5)] public EvePluginRuntimeAdvertisement Runtime { get; } + [Key(6)] public IReadOnlyList Schemas { get; } + [Key(7)] public IReadOnlyList ComponentKinds { get; } + [Key(8)] public IReadOnlyList Commands { get; } + [Key(9)] public IReadOnlyList Fixtures { get; } + } + + [MessagePackObject] + public sealed class EvePluginRuntimeAdvertisement + { + [SerializationConstructor] + public EvePluginRuntimeAdvertisement( + string invocationModel, + string contract, + IReadOnlyList transports, + IReadOnlyList authority, + EvePluginSidecarAdvertisement? sidecar) + { + InvocationModel = invocationModel ?? ""; + Contract = contract ?? ""; + Transports = transports ?? Array.Empty(); + Authority = authority ?? Array.Empty(); + Sidecar = sidecar; + } + + [Key(0)] public string InvocationModel { get; } + [Key(1)] public string Contract { get; } + [Key(2)] public IReadOnlyList Transports { get; } + [Key(3)] public IReadOnlyList Authority { get; } + [Key(4)] public EvePluginSidecarAdvertisement? Sidecar { get; } + } + + [MessagePackObject] + public sealed class EvePluginSidecarAdvertisement + { + [SerializationConstructor] + public EvePluginSidecarAdvertisement( + string processKind, + string protocol, + string requestSchema, + string responseSchema, + IReadOnlyList operations, + string commandEnvelope, + string receiptSchema, + string stateAuthority) + { + ProcessKind = processKind ?? ""; + Protocol = protocol ?? ""; + RequestSchema = requestSchema ?? ""; + ResponseSchema = responseSchema ?? ""; + Operations = operations ?? Array.Empty(); + CommandEnvelope = commandEnvelope ?? ""; + ReceiptSchema = receiptSchema ?? ""; + StateAuthority = stateAuthority ?? ""; + } + + [Key(0)] public string ProcessKind { get; } + [Key(1)] public string Protocol { get; } + [Key(2)] public string RequestSchema { get; } + [Key(3)] public string ResponseSchema { get; } + [Key(4)] public IReadOnlyList Operations { get; } + [Key(5)] public string CommandEnvelope { get; } + [Key(6)] public string ReceiptSchema { get; } + [Key(7)] public string StateAuthority { get; } + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta new file mode 100644 index 0000000..69b1c21 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a5b91c7defe47d2a38940a460aa59c5 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs index e9bb1da..ec8e37f 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -119,7 +119,8 @@ namespace GameCult.Eve.Surface string transport, string status, string surfaceKind, - EveWorldInteractionAdvertisement? worldInteraction = null) + EveWorldInteractionAdvertisement? worldInteraction = null, + IReadOnlyList? requiresPlugins = null) { SurfaceId = surfaceId ?? ""; Schema = schema ?? ""; @@ -128,6 +129,7 @@ namespace GameCult.Eve.Surface Status = status ?? ""; SurfaceKind = surfaceKind ?? ""; WorldInteraction = worldInteraction; + RequiresPlugins = requiresPlugins ?? Array.Empty(); } [Key(0)] public string SurfaceId { get; } @@ -137,6 +139,32 @@ namespace GameCult.Eve.Surface [Key(4)] public string Status { get; } [Key(5)] public string SurfaceKind { get; } [Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; } + [Key(7)] public IReadOnlyList RequiresPlugins { get; } + } + + [MessagePackObject] + public sealed class EvePluginRequirement + { + [SerializationConstructor] + public EvePluginRequirement( + string pluginId, + string versionRange, + string availability, + IReadOnlyList requiredCapabilities, + IReadOnlyList optionalCapabilities) + { + PluginId = pluginId ?? ""; + VersionRange = versionRange ?? ""; + Availability = string.IsNullOrWhiteSpace(availability) ? "required" : availability; + RequiredCapabilities = requiredCapabilities ?? Array.Empty(); + OptionalCapabilities = optionalCapabilities ?? Array.Empty(); + } + + [Key(0)] public string PluginId { get; } + [Key(1)] public string VersionRange { get; } + [Key(2)] public string Availability { get; } + [Key(3)] public IReadOnlyList RequiredCapabilities { get; } + [Key(4)] public IReadOnlyList OptionalCapabilities { get; } } [MessagePackObject] diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs index 280dd24..d4b2189 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs @@ -61,6 +61,41 @@ namespace GameCult.Eve.UnityScene.Tests new EveSurfaceRequest { LoweringTarget = "flutter" })); } + [Test] + public void ResolvePlugins_KeepsIndependentRequiredAndOptionalNestedCapabilities() + { + var surface = new EveAdvertisedSurface( + "sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface", + requiresPlugins: new[] + { + new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "vn.stage", "story.choose" }, Array.Empty()), + new EvePluginRequirement("norn.graph", "^0.1.0", "optional-nested", Array.Empty(), new[] { "embed.norn" }), + new EvePluginRequirement("tex.math", "^0.1.0", "optional-nested", Array.Empty(), new[] { "embed.tex" }) + }); + var sai = Plugin("sai.vn", new[] { "vn.stage" }, new[] { "story.choose" }); + var norn = Plugin("norn.graph", new[] { "embed.norn" }, Array.Empty()); + + var selected = EveCultMeshSurfaceSelection.ResolvePlugins(surface, new[] { sai, norn }); + + Assert.That(selected, Is.EqualTo(new[] { sai, norn })); + } + + [Test] + public void ResolvePlugins_RejectsMissingRequiredCapability() + { + var surface = new EveAdvertisedSurface( + "sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface", + requiresPlugins: new[] + { + new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "story.choose" }, Array.Empty()) + }); + + Assert.Throws(() => + EveCultMeshSurfaceSelection.ResolvePlugins( + surface, + new[] { Plugin("sai.vn", new[] { "vn.stage" }, Array.Empty()) })); + } + private static EveProviderAdvertisementDocument Provider(EveAdvertisedSurface surface) => new EveProviderAdvertisementDocument( "aetheria.daemon", @@ -75,5 +110,26 @@ namespace GameCult.Eve.UnityScene.Tests Array.Empty(), new[] { surface }, Array.Empty()); + + private static EvePluginAdvertisementDocument Plugin( + string pluginId, + string[] components, + string[] commands) => + new EvePluginAdvertisementDocument( + pluginId, + pluginId.Split('.')[0], + "0.1.0", + "cultmesh://plugins/" + pluginId, + new EvePluginRuntimeAdvertisement( + "sidecar", + "gamecult.eve.plugin_abi.v1", + new[] { "cultmesh" }, + new[] { "plugin" }, + new EvePluginSidecarAdvertisement( + "daemon", "cultmesh", "request", "response", Array.Empty(), "command", "receipt", "plugin")), + Array.Empty(), + components, + commands, + Array.Empty()); } } From e836884f164de3d730ea8edeb5b7147f644dc58a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 21:28:39 +0100 Subject: [PATCH 04/16] Lower EveUnity through the CultMesh client --- .../EveUnityCultMeshConnectionContext.cs | 84 ------- .../EveUnityCultMeshConnectionContext.cs.meta | 2 - .../EveUnityCultMeshLiveProviderTransport.cs | 230 ++++-------------- .../EveUnityCultMeshPlayableWorldProvider.cs | 9 +- .../EveUnityCultMeshProviderDiscovery.cs | 165 +++---------- 5 files changed, 86 insertions(+), 404 deletions(-) delete mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs delete mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs deleted file mode 100644 index d6f7abf..0000000 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.UnityScene -{ - public sealed class EveUnityCultMeshConnectionContext : IDisposable - { - private readonly CultMeshDiscoveryService _discovery; - - internal EveUnityCultMeshConnectionContext( - CultMeshEndpointId endpointId, - CultMeshDiscoveryService discovery, - CultMeshSessionManager sessions) - { - EndpointId = endpointId ?? throw new ArgumentNullException(nameof(endpointId)); - _discovery = discovery ?? throw new ArgumentNullException(nameof(discovery)); - Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); - } - - public CultMeshEndpointId EndpointId { get; } - public CultMeshSessionManager Sessions { get; } - - public Task OpenDocumentsAsync(CancellationToken cancellationToken = default) => - Sessions.ConnectAsync(EndpointId, CultMeshProtocols.Documents, cancellationToken); - - public void Dispose() - { - Sessions.Dispose(); - _discovery.Dispose(); - } - - internal static EveUnityCultMeshConnectionContext Create( - string endpointId, - IEnumerable routes) - { - var identity = CultMeshEndpointId.Parse(endpointId); - var discovery = new CultMeshDiscoveryService(new[] { new FixedRouteSource(routes) }); - return new EveUnityCultMeshConnectionContext( - identity, - discovery, - new CultMeshSessionManager(discovery, new ICultMeshTransportConnector[] - { - new CultMeshSchemaTransportConnector() - })); - } - - private sealed class FixedRouteSource : ICultMeshLookupSource - { - private readonly CultMeshVerseDescriptor[] _routes; - - public FixedRouteSource(IEnumerable routes) - { - _routes = routes?.ToArray() ?? throw new ArgumentNullException(nameof(routes)); - } - - public string SourceId => "eve-unity-rendezvous"; - - public Task> LookupAsync( - CultMeshDiscoveryQuery query, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var now = DateTimeOffset.UtcNow; - IReadOnlyList observations = _routes - .Where(route => string.Equals(route.VerseId, query.EndpointId, StringComparison.Ordinal)) - .Select(route => new CultMeshDiscoveryObservation( - route, - SourceId, - now, - now.AddMinutes(5), - CultMeshDiscoveryTrust.Unsigned, - "CultMesh rendezvous catalog")) - .ToArray(); - return Task.FromResult(observations); - } - } - } -} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta deleted file mode 100644 index 7d74b1b..0000000 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectionContext.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e2ece888321b9854fb4424a65448f703 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 0cae7e9..be4862b 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -34,12 +34,12 @@ namespace GameCult.Eve.UnityScene typeof(CultMeshCdnArtifactChunk) }; - private readonly string _replicaPath; - private readonly string _endpoint; + private readonly EveUnityCultMeshProviderSelection _selection; + private readonly CultMeshClient _mesh; + private readonly string _endpointId; private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; - private readonly EveUnityCultMeshConnectionContext? _connection; private CultMeshSession? _documentSession; private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); @@ -48,36 +48,24 @@ namespace GameCult.Eve.UnityScene private readonly List _assetBundles = new List(); private readonly Dictionary _cameraCullingMasks = new Dictionary(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); - private CultMeshNode? _node; - private CultMeshSnapshotSession? _snapshot; private CultNetDocumentRegistry? _networkRegistry; - private CultNetShardDescriptor? _replicaShard; - private CultNetDatabaseSubscriptionClient? _subscriptions; + private readonly List _liveWatches = new List(); + private bool _liveSubscriptionsOpen; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; public EveUnityCultMeshLiveProviderTransport( string replicaPath, - string endpoint, - string providerId, - string surfaceId, - string runtimeId = "eve-unity", - EveUnityCultMeshConnectionContext? connection = null) + EveUnityCultMeshProviderSelection selection, + string runtimeId = "eve-unity") { - _replicaPath = string.IsNullOrWhiteSpace(replicaPath) - ? throw new ArgumentException("Replica path must be non-empty.", nameof(replicaPath)) - : Path.GetFullPath(replicaPath); - _endpoint = string.IsNullOrWhiteSpace(endpoint) - ? throw new ArgumentException("CultMesh endpoint must be non-empty.", nameof(endpoint)) - : endpoint.Trim(); - _providerId = string.IsNullOrWhiteSpace(providerId) - ? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)) - : providerId.Trim(); - _surfaceId = string.IsNullOrWhiteSpace(surfaceId) - ? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId)) - : surfaceId.Trim(); + _ = replicaPath; + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _mesh = selection.Mesh; + _endpointId = selection.EndpointId; + _providerId = selection.ProviderId; + _surfaceId = selection.SurfaceId; _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); - _connection = connection; CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); } @@ -159,10 +147,9 @@ namespace GameCult.Eve.UnityScene SourceRuntimeId = _runtimeId, SourceRole = "eve-unity" }); - new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions()) - .ForwardPutAsync(_replicaShard!, message) - .GetAwaiter() - .GetResult(); + message.ShardId = RemoteShardId; + message.ShardEpoch = 1; + _documentSession!.SendCultNet(message); _pendingCommandIds.Add(commandId); } @@ -172,15 +159,11 @@ namespace GameCult.Eve.UnityScene _assetBundles.Clear(); _prefabs.Clear(); _nativeAssets.Clear(); - _node?.Dispose(); - _snapshot?.Dispose(); - _subscriptions?.Dispose(); - _connection?.Dispose(); - _node = null; - _snapshot = null; - _subscriptions = null; + foreach (var watch in _liveWatches) watch.Dispose(); + _liveWatches.Clear(); + _mesh.Dispose(); + _documentSession = null; _networkRegistry = null; - _replicaShard = null; _pendingCommandIds.Clear(); _publishedReceiptIds.Clear(); } @@ -206,103 +189,24 @@ namespace GameCult.Eve.UnityScene private void EnsureOpen() { - if (_node != null) + if (_documentSession != null) return; var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(WireDocumentTypes); _networkRegistry = CultMesh.CreateCultNetDocumentRegistry(WireDocumentTypes, cacheRegistry); - _replicaShard = new CultNetShardDescriptor( - RemoteShardId, - _providerId, - epoch: 1, - isPrimary: false, - schemaIds: WireDocumentTypes.Select(type => cacheRegistry.GetRequired(type).SchemaId), - primaryEndpoints: new[] { _endpoint }); - _node = CultMesh.CreateNodeAsync( - _replicaPath, - new CultMeshNodeOptions - { - StartServer = false, - EnableDurableShardLogs = true, - CacheOptions = new CultCacheOpenOptions - { - Registry = cacheRegistry, - PullOnOpen = false, - StoreFlushOnDispose = true, - UseDirectoryStore = true - }, - DatabaseOptions = new CultNetDatabaseOptions - { - RuntimeId = _runtimeId, - Shards = new[] { _replicaShard }, - DocumentRegistry = _networkRegistry - } - }) - .GetAwaiter() - .GetResult(); - if (_connection != null) - { - _documentSession = _connection.OpenDocumentsAsync().GetAwaiter().GetResult(); - _snapshot = CultMeshSnapshotSession.ConnectAsync( - _connection.Sessions, - _connection.EndpointId, - new CultMeshSnapshotRequestOptions - { - ShardId = RemoteShardId, - ShardEpoch = 1, - ConnectTimeout = TimeSpan.FromSeconds(5), - ResponseTimeout = TimeSpan.FromSeconds(10), - MessageIdPrefix = "eve-unity", - RudpRuntimeId = _runtimeId, - RudpMaxFragmentBytes = 1024 - }, - _networkRegistry) - .GetAwaiter().GetResult(); - return; - } - _snapshot = CultMesh.SnapshotSession( - _endpoint, - new CultMeshSnapshotRequestOptions - { - ShardId = RemoteShardId, - ShardEpoch = 1, - ConnectTimeout = TimeSpan.FromSeconds(5), - ResponseTimeout = TimeSpan.FromSeconds(10), - MessageIdPrefix = "eve-unity", - RudpRuntimeId = _runtimeId, - RudpMaxFragmentBytes = 1024 - }, - _networkRegistry); + _documentSession = _mesh.ConnectAsync(_endpointId, CultMeshProtocols.Documents) + .GetAwaiter().GetResult(); } private void ResolveAdvertisement() { - var documents = _snapshot! - .FetchDocumentsAsync() - .GetAwaiter() - .GetResult(); - _advertisement = documents.FirstOrDefault(document => - string.Equals(document.ProviderId, _providerId, StringComparison.Ordinal)) - ?? throw new InvalidOperationException($"Provider '{_providerId}' did not publish an Eve advertisement."); - _advertisedSurface = _advertisement.Surfaces.FirstOrDefault(surface => - string.Equals(surface.SurfaceId, _surfaceId, StringComparison.Ordinal)) - ?? throw new InvalidOperationException($"Provider '{_providerId}' did not advertise surface '{_surfaceId}'."); - if (!string.Equals(_advertisedSurface.Transport, "cultmesh-record", StringComparison.Ordinal)) - throw new InvalidOperationException("EveUnity CultMesh transport requires a cultmesh-record surface advertisement."); - if (string.IsNullOrWhiteSpace(_advertisedSurface.RecordRef)) - throw new InvalidOperationException("The provider surface advertisement does not publish a record reference."); + _advertisement = _selection.Surface.Provider; + _advertisedSurface = _selection.Surface.Advertisement; } private void RefreshSurface() { - var surface = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { _advertisedSurface!.RecordRef }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }) - .GetAwaiter() - .GetResult() - .FirstOrDefault() - ?? throw new InvalidOperationException("The advertised Eve surface record was not available."); + var surface = _selection.Surface.Surface.LatestAsync().GetAwaiter().GetResult(); PublishSurface(surface); } @@ -327,25 +231,9 @@ namespace GameCult.Eve.UnityScene private void EnsureLiveSubscriptions() { - if (_subscriptions != null) return; - var client = _documentSession?.OpenSchemaClient() - ?? CultNetSchemaClients.CreateForEndpoint(_endpoint); - if (_documentSession == null) - { - var endpoint = new Uri(_endpoint); - client.Connect(endpoint.Host, endpoint.Port); - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); - while (!client.Connected && DateTime.UtcNow < deadline) - System.Threading.Thread.Sleep(1); - if (!client.Connected) - { - client.Dispose(); - throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'."); - } - } - - _subscriptions = new CultNetDatabaseSubscriptionClient(client, _node!.Database.Cache, _networkRegistry!); - _subscriptions.Changed += OnReplicatedDocumentChanged; + if (_liveSubscriptionsOpen) return; + _liveSubscriptionsOpen = true; + _liveWatches.Add(_selection.Surface.Surface.Watch(surface => _liveDocuments.Enqueue(surface))); var lowered = new EveUnitySceneSurfaceLowerer() .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); string entityViewPointer = lowered.PlayableWorld == null @@ -353,31 +241,16 @@ namespace GameCult.Eve.UnityScene : lowered.PlayableWorld.EntityViewPointerId; if (!string.IsNullOrWhiteSpace(entityViewPointer)) { - _subscriptions.SubscribeAsync( - "eve-unity-entity-view", - recordKeys: new[] { entityViewPointer }, - schemaIds: new[] { EveEntitySoaViewDocument.SchemaId }) + var entities = _mesh.DocumentAsync(_endpointId, entityViewPointer) .GetAwaiter().GetResult(); - if (_node.Database.Cache.Get(new CultRecordKey(entityViewPointer)) is EveEntitySoaViewDocument current) - _liveDocuments.Enqueue(current); + _liveDocuments.Enqueue(entities.LatestAsync().GetAwaiter().GetResult()); + _liveWatches.Add(entities.Watch(view => _liveDocuments.Enqueue(view))); } - _subscriptions.SubscribeAsync( - "eve-unity-surface", - recordKeys: new[] { _advertisedSurface!.RecordRef }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }, - includeSnapshot: false) - .GetAwaiter().GetResult(); - _subscriptions.SubscribeAsync( - "eve-unity-receipts", - schemaIds: new[] { EveCommandReceiptDocument.SchemaId }, - includeSnapshot: false) - .GetAwaiter().GetResult(); - } - - private void OnReplicatedDocumentChanged(CultNetReplicatedDocumentChange change) - { - if (change.Document is EveEntitySoaViewDocument or EveSurfaceDocument or EveCommandReceiptDocument) - _liveDocuments.Enqueue(change.Document); + var receipts = _mesh.CollectionAsync(_endpointId).GetAwaiter().GetResult(); + _liveWatches.Add(receipts.WatchChanges(change => + { + if (change.Document != null) _liveDocuments.Enqueue(change.Document); + })); } private void RefreshAssetCatalog() @@ -386,13 +259,8 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) return; - var catalog = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { interaction.AssetManifestRecordRef }, - schemaIds: new[] { EveAssetCatalogDocument.SchemaId }) - .GetAwaiter() - .GetResult() - .FirstOrDefault(); + var catalog = _mesh.ReadAsync(_endpointId, interaction.AssetManifestRecordRef) + .GetAwaiter().GetResult(); if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) return; @@ -415,14 +283,8 @@ namespace GameCult.Eve.UnityScene foreach (var group in selected.GroupBy(selection => selection.Variant!.Uri, StringComparer.Ordinal)) { var variant = group.First().Variant!; - var manifest = _snapshot!.FetchDocumentsAsync( - recordKeys: new[] { variant.Uri }, - schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactManifest }) - .GetAwaiter() - .GetResult(); - var descriptor = manifest.FirstOrDefault(); - if (descriptor == null) - throw new InvalidOperationException($"Provider asset bundle manifest '{variant.Uri}' was not available."); + var descriptor = _mesh.ReadAsync(_endpointId, variant.Uri) + .GetAwaiter().GetResult(); var bytes = ReadCachedBundle(descriptor, variant); VerifyBundle(bytes, variant); var bundle = AssetBundle.LoadFromMemory(bytes); @@ -489,12 +351,10 @@ namespace GameCult.Eve.UnityScene for (var index = 0; index < references.Length; index += 2) { var batch = references.Skip(index).Take(2).ToArray(); - var chunks = _snapshot! - .FetchDocumentsAsync( - recordKeys: batch.Select(reference => reference.RecordKey).ToArray(), - schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactChunk }) - .GetAwaiter() - .GetResult(); + var chunks = batch.Select(reference => _mesh + .ReadAsync(_endpointId, reference.RecordKey) + .GetAwaiter().GetResult()) + .ToArray(); foreach (var reference in batch) { var chunk = chunks.FirstOrDefault(candidate => diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index 884c818..c606e19 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -126,7 +126,7 @@ namespace GameCult.Eve.UnityScene _transport = null; Selection = null; if (!transportOwnedConnection && _discovery?.Status == TaskStatus.RanToCompletion) - _discovery.Result.Connection.Dispose(); + _discovery.Result.Mesh.Dispose(); _discovery = null; } @@ -185,11 +185,8 @@ namespace GameCult.Eve.UnityScene : replicaPath; _transport = new EveUnityCultMeshLiveProviderTransport( resolvedReplicaPath, - Selection.Endpoint, - Selection.ProviderId, - Selection.SurfaceId, - runtimeId, - Selection.Connection); + Selection, + runtimeId); _transport.EntityViewAvailable += view => _pendingEntityViews.Enqueue(view); _bridge = new EveUnitySceneLiveProviderBridge(_transport); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index cb6a641..c08809e 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Mesh; -using GameCult.Networking; #nullable enable @@ -13,46 +10,27 @@ namespace GameCult.Eve.UnityScene { public sealed class EveUnityCultMeshProviderSelection { - public EveUnityCultMeshProviderSelection( - string endpoint, - string verseId, - string providerId, - string surfaceId, - string surfaceKind, - EveUnityCultMeshConnectionContext connection) + internal EveUnityCultMeshProviderSelection( + string endpointId, + CultMeshClient mesh, + EveSurfaceSelection surface) { - Endpoint = endpoint ?? ""; - VerseId = verseId ?? ""; - ProviderId = providerId ?? ""; - SurfaceId = surfaceId ?? ""; - SurfaceKind = surfaceKind ?? ""; - Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + EndpointId = endpointId; + Mesh = mesh; + Surface = surface; } - public string Endpoint { get; } - public string VerseId { get; } - public string ProviderId { get; } - public string SurfaceId { get; } - public string SurfaceKind { get; } - public EveUnityCultMeshConnectionContext Connection { get; } + public string EndpointId { get; } + public string VerseId => Surface.Provider.VerseId; + public string ProviderId => Surface.Provider.ProviderId; + public string SurfaceId => Surface.Advertisement.SurfaceId; + public string SurfaceKind => Surface.Advertisement.SurfaceKind; + public CultMeshClient Mesh { get; } + public EveSurfaceSelection Surface { get; } } public sealed class EveUnityCultMeshProviderDiscovery { - private static readonly Type[] AdvertisementDocumentTypes = - { - typeof(EveProviderAdvertisementDocument) - }; - - public EveUnityCultMeshProviderSelection Discover( - string rendezvousEndpoint, - string providerId = "", - string surfaceId = "", - string surfaceKind = "interactive-world", - string verseId = "") - => DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId) - .GetAwaiter().GetResult(); - public async Task DiscoverAsync( string rendezvousEndpoint, string providerId = "", @@ -63,100 +41,33 @@ namespace GameCult.Eve.UnityScene { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); + var endpointId = !string.IsNullOrWhiteSpace(verseId) ? verseId : providerId; + if (string.IsNullOrWhiteSpace(endpointId)) + throw new ArgumentException("Provider or Verse identity must be supplied for CultMesh discovery.", nameof(providerId)); - var response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync( - rendezvousEndpoint, - new CultMeshVerseCatalogRequestMessage - { - VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId }, - TransportVersion = "cultmesh.v0" - }).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - var candidates = response.Verses - .Where(verse => string.IsNullOrWhiteSpace(verseId) || - string.Equals(verse.VerseId, verseId, StringComparison.Ordinal)) - .SelectMany(verse => (verse.DiscoveryEndpoints ?? Array.Empty()) - .Select(endpoint => new { Verse = verse, Endpoint = endpoint })) - .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Endpoint)) - .ToArray(); - if (candidates.Length == 0) - throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints."); - - var failures = new List(); - foreach (var candidate in candidates) + var mesh = new CultMeshClient(new CultMeshClientOptions { - EveUnityCultMeshConnectionContext? connection = null; - try - { - connection = EveUnityCultMeshConnectionContext.Create( - candidate.Verse.VerseId, - new[] { candidate.Verse.ToVerseDescriptor() }); - var advertisement = (await FetchAdvertisementsAsync(connection, cancellationToken).ConfigureAwait(false)) - .Where(document => string.IsNullOrWhiteSpace(providerId) || - string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) - .Select(document => new - { - Document = document, - Surface = document.Surfaces.FirstOrDefault(surface => - (string.IsNullOrWhiteSpace(surfaceId) || - string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) && - (string.IsNullOrWhiteSpace(surfaceKind) || - string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal))) - }) - .FirstOrDefault(match => match.Surface != null); - if (advertisement?.Surface == null) - continue; - - return new EveUnityCultMeshProviderSelection( - candidate.Endpoint, - candidate.Verse.VerseId, - advertisement.Document.ProviderId, - advertisement.Surface.SurfaceId, - advertisement.Surface.SurfaceKind, - connection); - } - catch (Exception error) - { - connection?.Dispose(); - failures.Add($"{candidate.Endpoint}: {error.Message}"); - } - } - - var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'"; - var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}"; - throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}"); - } - - private static async Task FetchAdvertisementsAsync( - EveUnityCultMeshConnectionContext connection, - CancellationToken cancellationToken) - { - var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes); - var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry); - using var snapshot = await CultMeshSnapshotSession.ConnectAsync( - connection.Sessions, - connection.EndpointId, - new CultMeshSnapshotEndpointOptions - { - Context = CultMesh.Verse("eve.discovery", "eve-unity").Context, - DocumentRegistry = networkRegistry, - Request = new CultMeshSnapshotRequestOptions + RendezvousEndpoints = new[] { rendezvousEndpoint } + }); + try + { + var surface = await mesh.SurfaceAsync( + endpointId, + new EveSurfaceRequest { - ShardId = "provider", - ShardEpoch = 1, - ConnectTimeout = TimeSpan.FromSeconds(5), - ResponseTimeout = TimeSpan.FromSeconds(10), - MessageIdPrefix = "eve-unity-discovery", - RudpRuntimeId = "eve-unity.discovery", - RudpMaxFragmentBytes = 1024 - } - }.Request, - networkRegistry, - cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - return (await snapshot.FetchDocumentsAsync() - .ConfigureAwait(false)).ToArray(); + ProviderId = providerId, + SurfaceId = surfaceId, + SurfaceKind = surfaceKind, + LoweringTarget = "unity-scene" + }, + cancellationToken).ConfigureAwait(false); + return new EveUnityCultMeshProviderSelection(endpointId, mesh, surface); + } + catch + { + mesh.Dispose(); + throw; + } } } } From 66401bedaadd521e9c90ff314c05319377f1d5b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 22:23:52 +0100 Subject: [PATCH 05/16] Separate daemon build from witness readiness --- scripts/run-aetheria-daemon-world-witness.ps1 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index 137d7c4..d1b5185 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -17,6 +17,7 @@ $resultsPath = Join-Path $outputRoot "results.xml" $unityLogPath = Join-Path $outputRoot "unity.log" $bundleBuildLogPath = Join-Path $outputRoot "asset-bundle-build.log" $daemonLogPath = Join-Path $outputRoot "aetheria-daemon.log" +$daemonBuildLogPath = Join-Path $outputRoot "aetheria-daemon-build.log" $capturePath = Join-Path $outputRoot "aetheria-daemon-world.png" $factsPath = Join-Path $outputRoot "witness-facts.json" $witnessPath = Join-Path $outputRoot "runtime-witness.json" @@ -63,6 +64,14 @@ $import = Start-Process -FilePath "dotnet" -ArgumentList @( if ($import.ExitCode -ne 0) { throw "Aetheria state import failed with exit code $($import.ExitCode). See $outputRoot\aetheria-import.error.log" } +$daemonBuild = Start-Process -FilePath "dotnet" -ArgumentList @( + "build", $daemonProject +) -PassThru -WindowStyle Hidden -Wait ` + -RedirectStandardOutput $daemonBuildLogPath ` + -RedirectStandardError (Join-Path $outputRoot "aetheria-daemon-build.error.log") +if ($daemonBuild.ExitCode -ne 0) { + throw "Aetheria daemon build failed with exit code $($daemonBuild.ExitCode). See $daemonBuildLogPath" +} if (-not $SkipAssetBundleBuild) { $bundleBuilder = Start-Process -FilePath $UnityExe -ArgumentList @( "-batchmode", "-quit", "-projectPath", ".", @@ -83,7 +92,7 @@ if (-not $SkipAssetBundleBuild) { } $daemonArguments = @( - "run", "--project", $daemonProject, "--", + "run", "--no-build", "--project", $daemonProject, "--", "--root", $AetheriaRoot, "--state", $statePath, "--client-cultmesh-host", "127.0.0.1", From 93551237fa8b627b6887bf09d68acfede586b160 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 22:23:53 +0100 Subject: [PATCH 06/16] Complete generic Unity client state handoff --- .../EveUnityCultMeshLiveProviderTransport.cs | 29 ++++++++++++++----- .../EveUnityCultMeshPlayableWorldProvider.cs | 7 +++++ .../EveUnityCultMeshProviderDiscovery.cs | 6 ++-- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index be4862b..1b06585 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -4,6 +4,8 @@ using System.IO; using System.Linq; using System.Security.Cryptography; using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Eve.Surface; @@ -348,13 +350,20 @@ namespace GameCult.Eve.UnityScene { var bytes = new byte[checked((int)manifest.SizeBytes)]; var references = manifest.Chunks.OrderBy(chunk => chunk.Offset).ToArray(); - for (var index = 0; index < references.Length; index += 2) + var batches = references.Select((reference, index) => new { reference, index }) + .GroupBy(value => value.index / 2) + .Select(group => group.Select(value => value.reference).ToArray()) + .ToArray(); + using var concurrency = new SemaphoreSlim(2, 2); + var downloads = batches.Select(async batch => { - var batch = references.Skip(index).Take(2).ToArray(); - var chunks = batch.Select(reference => _mesh - .ReadAsync(_endpointId, reference.RecordKey) - .GetAwaiter().GetResult()) - .ToArray(); + await concurrency.WaitAsync().ConfigureAwait(false); + try + { + var chunks = await _mesh.ReadManyAsync( + _endpointId, + batch.Select(reference => reference.RecordKey).ToArray()) + .ConfigureAwait(false); foreach (var reference in batch) { var chunk = chunks.FirstOrDefault(candidate => @@ -365,7 +374,13 @@ namespace GameCult.Eve.UnityScene throw new InvalidDataException($"Provider asset chunk '{reference.RecordKey}' size did not match its manifest."); Buffer.BlockCopy(chunk.Payload, 0, bytes, checked((int)reference.Offset), reference.SizeBytes); } - } + } + finally + { + concurrency.Release(); + } + }).ToArray(); + Task.WhenAll(downloads).GetAwaiter().GetResult(); return bytes; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index c606e19..1373276 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -92,6 +92,8 @@ namespace GameCult.Eve.UnityScene Bridge.Refresh(); else Bridge.Connect(); + _transport?.PumpLiveEvents(); + DrainEntityViews(); } public void Submit(EveSurfaceCommandRequest request) @@ -134,6 +136,11 @@ namespace GameCult.Eve.UnityScene { CompleteDiscoveryOnMainThread(); _transport?.PumpLiveEvents(); + DrainEntityViews(); + } + + private void DrainEntityViews() + { EveEntitySoaViewDocument? latest = null; while (_pendingEntityViews.TryDequeue(out var next)) latest = next; if (latest == null) return; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index c08809e..8354c2d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -41,9 +41,9 @@ namespace GameCult.Eve.UnityScene { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); - var endpointId = !string.IsNullOrWhiteSpace(verseId) ? verseId : providerId; - if (string.IsNullOrWhiteSpace(endpointId)) - throw new ArgumentException("Provider or Verse identity must be supplied for CultMesh discovery.", nameof(providerId)); + var endpointId = !string.IsNullOrWhiteSpace(verseId) + ? verseId + : !string.IsNullOrWhiteSpace(providerId) ? providerId : "eve.providers"; var mesh = new CultMeshClient(new CultMeshClientOptions { From 838a5ac92384147275c2d600702b1a70fb4119e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 22:43:40 +0100 Subject: [PATCH 07/16] Report authoritative SoA entity state --- .../Runtime/EveUnityEntitySoaPresenter.cs | 2 + .../Runtime/EveUnityPlayableWorldRuntime.cs | 26 ++++++-- .../EveUnitySceneSurfaceLowererTests.cs | 65 ++++++++++++++++++- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs index de83f46..87b3c84 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs @@ -24,6 +24,8 @@ namespace GameCult.Eve.UnityScene _sink = sink ?? throw new ArgumentNullException(nameof(sink)); } + public int ActiveEntities => _known.Count; + public void Apply(EveEntitySoaViewDocument document) { using var view = EveUnityEntitySoaView.Open(document); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs index 0fc0ad1..589dba1 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -52,7 +52,7 @@ namespace GameCult.Eve.UnityScene public EveUnitySceneProjection? ActiveProjection => _client.ActiveProjection; - public EveUnityPlayableWorldPresentation? LastPresentation => _client.LastPresentation; + public EveUnityPlayableWorldPresentation? LastPresentation => ComposePresentation(_client.LastPresentation); public EveUnitySceneCommandReceipt? LastReceipt => _client.LastReceipt; @@ -109,15 +109,17 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldPresentation Connect() { EnsureAssetManifestConnected(); - var presentation = _client.Connect(); + _client.Connect(); EnsureEntityViewsConnected(); - return presentation; + return ComposePresentation(_client.LastPresentation)!; } public EveUnityPlayableWorldPresentation Refresh() { EnsureAssetManifestConnected(); - return _client.Refresh(); + EnsureEntityViewsConnected(); + _client.Refresh(); + return ComposePresentation(_client.LastPresentation)!; } public EveSurfaceCommandRequest SubmitMoveIntent( @@ -207,6 +209,22 @@ namespace GameCult.Eve.UnityScene } private void OnEntityViewAvailable(EveEntitySoaViewDocument document) => _entityPresenter.Apply(document); + + private EveUnityPlayableWorldPresentation? ComposePresentation( + EveUnityPlayableWorldPresentation? surfacePresentation) + { + if (surfacePresentation == null || _entityViews?.CurrentEntityView == null) + return surfacePresentation; + + return new EveUnityPlayableWorldPresentation( + surfacePresentation.WorldRootId, + surfacePresentation.PlayerEntityId, + surfacePresentation.InputProfile, + surfacePresentation.CameraRig, + surfacePresentation.UpsertedEntities, + surfacePresentation.RemovedEntities, + _entityPresenter.ActiveEntities); + } } public sealed class EveUnityLivePlayableWorldAssetProvider : IEveUnityNativeAssetProvider diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 10db12d..340d752 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -419,6 +419,52 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True); } + [Test] + public void PlayableWorldRuntimeReportsAuthoritativeSoaEntityCountOnConnect() + { + var location = $"eve-unity-runtime-soa-{Guid.NewGuid():N}"; + using var mapped = MemoryMappedFile.CreateNew(location, 16); + using (var writer = mapped.CreateViewAccessor()) + { + writer.Write(0, 4f); + writer.Write(4, 5f); + writer.Write(8, 6f); + writer.Write(12, 41); + } + var entityView = new EveEntitySoaViewDocument + { + ProviderId = "aetheria", + ViewId = "pilot", + Generation = 1, + Backend = "memory_mapped_file", + Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", Backend = "memory_mapped_file", Location = location, ByteLength = 16, Generation = 1 } }, + Columns = new[] + { + new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 1 }, + new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 12, ElementStride = 4, ElementCount = 1 } + } + }; + var surfaceDocuments = new FakeProviderSurfaceDocumentSource( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + entityView); + var sceneSink = new FakePlayableWorldSceneSink(); + + using var runtime = new EveUnityPlayableWorldRuntime( + surfaceDocuments, + new FakeCommandSink("cultmesh-command-sink"), + sceneSink); + + var presentation = runtime.Connect(); + + Assert.That(presentation.ActiveEntities, Is.EqualTo(1)); + Assert.That(runtime.LastPresentation!.ActiveEntities, Is.EqualTo(1)); + Assert.That(sceneSink.Upserts.Exists(entry => entry.entity.EntityId == "entity:41"), Is.True); + } + [Test] public void LiveProviderBridgeFeedsPlayableWorldRuntimeThroughTransportPorts() { @@ -1544,22 +1590,37 @@ namespace GameCult.Eve.UnityScene.Tests } } - private sealed class FakeProviderSurfaceDocumentSource : IEveUnitySceneProviderSurfaceDocumentSource + private sealed class FakeProviderSurfaceDocumentSource : + IEveUnitySceneProviderSurfaceDocumentSource, + IEveUnityEntitySoaViewDocumentSource { - public FakeProviderSurfaceDocumentSource(EveUnitySceneProviderSurfaceDocument currentDocument) + public FakeProviderSurfaceDocumentSource( + EveUnitySceneProviderSurfaceDocument currentDocument, + EveEntitySoaViewDocument? currentEntityView = null) { CurrentDocument = currentDocument; + CurrentEntityView = currentEntityView; } public EveUnitySceneProviderSurfaceDocument CurrentDocument { get; private set; } public event Action? DocumentAvailable; + public EveEntitySoaViewDocument? CurrentEntityView { get; private set; } + + public event Action? EntityViewAvailable; + public void Publish(EveUnitySceneProviderSurfaceDocument document) { CurrentDocument = document; DocumentAvailable?.Invoke(document); } + + public void PublishEntityView(EveEntitySoaViewDocument document) + { + CurrentEntityView = document; + EntityViewAvailable?.Invoke(document); + } } private sealed class FakeCommandReceiptSource : IEveUnitySceneCommandReceiptSource From cf9a8652a33d2724813b8d66cad5ca2df78e7ff8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 23:27:44 +0100 Subject: [PATCH 08/16] Define portable Eve world identity and input contracts --- .../GameCult.Eve.Contracts.csproj | 17 ++++++ .../GameCult.Eve.Contracts.csproj.meta | 7 +++ .../GameCult.Eve.Surface.csproj | 5 +- .../Runtime/EveEntitySoaViewDocument.cs | 14 +++++ .../Runtime/EveInputCapabilityDocument.cs | 59 +++++++++++++++++++ .../EveInputCapabilityDocument.cs.meta | 2 + 6 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj create mode 100644 packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj new file mode 100644 index 0000000..6b453f0 --- /dev/null +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj @@ -0,0 +1,17 @@ + + + netstandard2.1 + enable + latest + enable + GameCult.Eve.Surface + Portable Eve surface, provider, input, plugin, and entity-view contracts. + $(MSBuildThisFileDirectory)..\..\..\CultLib + + + + + + + + diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj.meta b/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj.meta new file mode 100644 index 0000000..3b5cba0 --- /dev/null +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8a186b6d206ffa847aa276e984162512 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj index 16839dd..f07f7e8 100644 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj @@ -5,10 +5,11 @@ latest enable Portable Eve surface and provider contracts. + $(MSBuildThisFileDirectory)..\..\..\CultLib - - + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs index 222b4a1..efe1caf 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs @@ -24,6 +24,20 @@ namespace GameCult.Eve.Surface [Key(9)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty(); [Key(10)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty(); [Key(11)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty(); + [Key(12)] public EveEntitySoaIdentity[] Identities { get; set; } = Array.Empty(); + } + + [MessagePackObject] + public sealed class EveEntitySoaIdentity + { + [Key(0)] public int EntityIndex { get; set; } = -1; + [Key(1)] public string EntityId { get; set; } = ""; + [Key(2)] public string Kind { get; set; } = ""; + [Key(3)] public string Label { get; set; } = ""; + [Key(4)] public string Faction { get; set; } = ""; + [Key(5)] public bool Selectable { get; set; } = true; + [Key(6)] public bool Controllable { get; set; } + [Key(7)] public string AssetRef { get; set; } = ""; } [MessagePackObject] diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs new file mode 100644 index 0000000..356edee --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using MessagePack; + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.input_capability", SchemaId)] + [MessagePackObject] + public sealed class EveInputCapabilityDocument + { + public const string SchemaId = "gamecult.eve.input_capability.v1"; + [Key(0)] public string Schema { get; set; } = SchemaId; + [Key(1)] public string ProviderId { get; set; } = ""; + [Key(2)] public string CapabilityId { get; set; } = ""; + [Key(3)] public long Version { get; set; } + [Key(4)] public EveInputActionDocument[] Actions { get; set; } = Array.Empty(); + [Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty(); + } + + [MessagePackObject] + public sealed class EveInputActionDocument + { + [Key(0)] public string ActionId { get; set; } = ""; + [Key(1)] public string Label { get; set; } = ""; + [Key(2)] public string Operation { get; set; } = ""; + [Key(3)] public string Context { get; set; } = "pilot"; + [Key(4)] public string Category { get; set; } = "ship"; + [Key(5)] public string Availability { get; set; } = "available"; + [Key(6)] public string SourceRef { get; set; } = ""; + [Key(7)] public Dictionary Payload { get; set; } = new Dictionary(StringComparer.Ordinal); + } + + [MessagePackObject] + public sealed class EveInputProfileDocument + { + [Key(0)] public string ProfileId { get; set; } = ""; + [Key(1)] public string DeviceClass { get; set; } = ""; + [Key(2)] public EveInputBindingDocument[] Bindings { get; set; } = Array.Empty(); + } + + [MessagePackObject] + public sealed class EveInputBindingDocument + { + [Key(0)] public string BindingId { get; set; } = ""; + [Key(1)] public string ActionId { get; set; } = ""; + [Key(2)] public EveInputGestureDocument Gesture { get; set; } = new EveInputGestureDocument(); + [Key(3)] public bool ActionBar { get; set; } + } + + [MessagePackObject] + public sealed class EveInputGestureDocument + { + [Key(0)] public string Kind { get; set; } = "direct"; + [Key(1)] public string[] Controls { get; set; } = Array.Empty(); + [Key(2)] public int MaxStepIntervalMs { get; set; } = 650; + [Key(3)] public string CompletionControl { get; set; } = ""; + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta new file mode 100644 index 0000000..9a01871 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c297c7dbb64a0f49a7f805e770c5e55 \ No newline at end of file From 1a27b5e041b5844ab8da82bbe3502d9e01598f86 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 23:28:01 +0100 Subject: [PATCH 09/16] Lower daemon identities and semantic actions in Unity --- .../PlayMode/GenericWorldCaptureTests.cs | 10 +++- .../EveUnityCultMeshLiveProviderTransport.cs | 13 +++++ .../EveUnityCultMeshPlayableWorldProvider.cs | 3 ++ .../Runtime/EveUnityEntitySoaPresenter.cs | 30 ++++++++---- .../EveUnityPlayableWorldLiveClient.cs | 4 +- .../Runtime/EveUnityPlayableWorldRuntime.cs | 12 ++++- .../Runtime/EveUnitySceneClientSession.cs | 17 +++---- .../EveUnitySceneProviderConnection.cs | 4 +- .../Runtime/EveUnitySceneSurfaceLowerer.cs | 9 +++- .../EveUnitySceneSurfaceLowererTests.cs | 47 +++++++++++++++---- scripts/run-aetheria-daemon-world-witness.ps1 | 7 ++- 11 files changed, 122 insertions(+), 34 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index d5dc99b..5b3e278 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Eve.Surface; @@ -276,7 +277,14 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); var focusVersion = runtime.ActiveVersion; - var action = runtime.SubmitActionIntent(playerId, "0"); + Assert.That(provider.CurrentInputCapability, Is.Not.Null, "The pilot world did not advertise a typed input capability document."); + Assert.That(provider.CurrentInputCapability.Actions, Is.Not.Empty, "The pilot input capability advertised no available actions."); + var advertisedAction = provider.CurrentInputCapability.Actions + .FirstOrDefault(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)) + ?? provider.CurrentInputCapability.Actions + .First(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); + var action = runtime.SubmitActionIntent(playerId, advertisedAction.ActionId); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId || diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 1b06585..c74bff9 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -32,6 +32,7 @@ namespace GameCult.Eve.UnityScene typeof(EveCommandReceiptDocument), typeof(EveAssetCatalogDocument), typeof(EveEntitySoaViewDocument), + typeof(EveInputCapabilityDocument), typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk) }; @@ -82,6 +83,8 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; } + public EveInputCapabilityDocument? CurrentInputCapability { get; private set; } + public event Action? SurfaceDocumentAvailable; public event Action? AssetManifestDocumentAvailable; @@ -118,6 +121,8 @@ namespace GameCult.Eve.UnityScene PublishSurface(surface); else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); + else if (document is EveInputCapabilityDocument inputCapability) + CurrentInputCapability = inputCapability; } } @@ -248,6 +253,14 @@ namespace GameCult.Eve.UnityScene _liveDocuments.Enqueue(entities.LatestAsync().GetAwaiter().GetResult()); _liveWatches.Add(entities.Watch(view => _liveDocuments.Enqueue(view))); } + var inputCapabilityPointer = lowered.PlayableWorld?.InputCapabilityPointerId ?? ""; + if (!string.IsNullOrWhiteSpace(inputCapabilityPointer)) + { + var inputs = _mesh.DocumentAsync(_endpointId, inputCapabilityPointer) + .GetAwaiter().GetResult(); + CurrentInputCapability = inputs.LatestAsync().GetAwaiter().GetResult(); + _liveWatches.Add(inputs.Watch(document => _liveDocuments.Enqueue(document))); + } var receipts = _mesh.CollectionAsync(_endpointId).GetAwaiter().GetResult(); _liveWatches.Add(receipts.WatchChanges(change => { diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index 1373276..71c2425 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -17,6 +17,7 @@ namespace GameCult.Eve.UnityScene IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, IEveUnityEntitySoaViewDocumentSource, + IEveUnityInputCapabilityDocumentSource, IEveUnityCameraRenderPolicySource, IEveUnityNativeAssetProvider { @@ -35,6 +36,8 @@ namespace GameCult.Eve.UnityScene public EveEntitySoaViewDocument? CurrentEntityView { get; private set; } + public EveInputCapabilityDocument? CurrentInputCapability => _transport?.CurrentInputCapability; + public event Action? EntityViewAvailable; public EveUnityCultMeshProviderSelection? Selection { get; private set; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs index 87b3c84..31ba21c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs @@ -13,6 +13,11 @@ namespace GameCult.Eve.UnityScene event Action EntityViewAvailable; } + public interface IEveUnityInputCapabilityDocumentSource + { + EveInputCapabilityDocument? CurrentInputCapability { get; } + } + /// Projects portable entity SoA generations into the ordinary Unity scene sink. public sealed class EveUnityEntitySoaPresenter { @@ -29,6 +34,10 @@ namespace GameCult.Eve.UnityScene public void Apply(EveEntitySoaViewDocument document) { using var view = EveUnityEntitySoaView.Open(document); + var identities = (document.Identities ?? Array.Empty()) + .Where(identity => identity != null && identity.EntityIndex >= 0 && !string.IsNullOrWhiteSpace(identity.EntityId)) + .GroupBy(identity => identity.EntityIndex) + .ToDictionary(group => group.Key, group => group.First()); var next = new HashSet(StringComparer.Ordinal); for (var row = 0; row < view.EntityCount; row++) { @@ -39,24 +48,29 @@ namespace GameCult.Eve.UnityScene view.TryReadFloat("render.scale", row, out var scale); view.TryReadUInt32("render.group.id", row, out var groupId); var renderGroup = view.RenderGroups.FirstOrDefault(group => group.GroupId == groupId); - var entityId = $"entity:{entityIndex}"; + if (!identities.TryGetValue(entityIndex, out var identity)) + throw new InvalidOperationException( + $"Eve entity SoA view '{document.ViewId}' does not map entity index {entityIndex} to a logical identity."); + var entityId = identity.EntityId; next.Add(entityId); - var assetRef = renderGroup?.MeshAssetRef ?? ""; + var assetRef = string.IsNullOrWhiteSpace(identity.AssetRef) + ? renderGroup?.MeshAssetRef ?? "" + : identity.AssetRef; _sink.UpsertEntity( new EveUnityPlayableWorldEntity( entityId, entityId, - "entity", - entityId, - "", + identity.Kind, + string.IsNullOrWhiteSpace(identity.Label) ? entityId : identity.Label, + identity.Faction, assetRef, position.x, position.y, position.z, rotationRadians * 57.2957795f, scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f, - selectable: true, - controllable: false, + selectable: identity.Selectable, + controllable: identity.Controllable, focusCommand: "", moveCommand: "", targetCommand: "", @@ -64,7 +78,7 @@ namespace GameCult.Eve.UnityScene props: new Dictionary()), new EveUnityPlayableWorldAssetBinding( assetRef, - "entity", + identity.Kind, string.IsNullOrWhiteSpace(assetRef) ? "unity-generated-placeholder" : "provider-asset-ref")); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index bf44ee2..e1ab229 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -105,10 +105,10 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest SubmitActionIntent( string entityId, - string actionId, + EveInputActionDocument action, DateTimeOffset? issuedAt = null) { - return _connection.SubmitActionIntent(entityId, actionId, issuedAt); + return _connection.SubmitActionIntent(entityId, action, issuedAt); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs index 589dba1..82f8bb2 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using GameCult.Eve.Surface; using UnityEngine; @@ -13,6 +14,7 @@ namespace GameCult.Eve.UnityScene private readonly EveUnityPlayableWorldLiveClient _client; private readonly EveUnityPlayableWorldAssetManifestDocumentSource? _assetManifestSource; private readonly IEveUnityEntitySoaViewDocumentSource? _entityViews; + private readonly IEveUnityInputCapabilityDocumentSource? _inputCapabilities; private readonly EveUnityEntitySoaPresenter _entityPresenter; private bool _entityViewsConnected; private bool _assetManifestConnected; @@ -33,6 +35,7 @@ namespace GameCult.Eve.UnityScene AssetManifests = assetManifests ?? new EveUnityPlayableWorldAssetManifestCache(); _surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(surfaceDocuments); _entityViews = surfaceDocuments as IEveUnityEntitySoaViewDocumentSource; + _inputCapabilities = surfaceDocuments as IEveUnityInputCapabilityDocumentSource; _entityPresenter = new EveUnityEntitySoaPresenter(sceneSink); _connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink); _client = new EveUnityPlayableWorldLiveClient( @@ -162,7 +165,14 @@ namespace GameCult.Eve.UnityScene string actionId, DateTimeOffset? issuedAt = null) { - return _client.SubmitActionIntent(entityId, actionId, issuedAt); + var capability = _inputCapabilities?.CurrentInputCapability + ?? throw new InvalidOperationException("The active Eve surface does not publish an input capability document."); + var action = (capability.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => string.Equals(candidate.ActionId, actionId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"The active Eve input capability does not advertise action '{actionId}'."); + if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Eve action '{actionId}' is not currently available."); + return _client.SubmitActionIntent(entityId, action, issuedAt); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs index e82cf56..16efb3d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs @@ -115,17 +115,18 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest CreateActionIntent( string entityId, - string actionId, + EveInputActionDocument action, DateTimeOffset? issuedAt = null) { - var playableWorld = RequirePlayableWorld(); + if (action == null) throw new ArgumentNullException(nameof(action)); + var payload = new Dictionary(action.Payload ?? new Dictionary(), StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "", + ["actionId"] = action.ActionId ?? "" + }; return CreatePlayableWorldIntent( - playableWorld.ActionCommand, - new Dictionary(StringComparer.Ordinal) - { - ["entityId"] = entityId ?? "", - ["actionId"] = actionId ?? "" - }, + action.Operation, + payload, issuedAt); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs index e21292c..de6037c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs @@ -238,10 +238,10 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest SubmitActionIntent( string entityId, - string actionId, + EveInputActionDocument action, DateTimeOffset? issuedAt = null) { - return Submit(_session.CreateActionIntent(entityId, actionId, issuedAt)); + return Submit(_session.CreateActionIntent(entityId, action, issuedAt)); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index ae13226..d5b5905 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -124,7 +124,8 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("entityViewPointerId"), worldRoot.GetProp("entityViewSchema"), worldRoot.GetProp("zoneRenderPointerId"), - worldRoot.GetProp("zoneRenderSchema")); + worldRoot.GetProp("zoneRenderSchema"), + worldRoot.GetProp("inputCapability")); } private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component) @@ -324,7 +325,8 @@ namespace GameCult.Eve.UnityScene string entityViewPointerId = "", string entityViewSchema = "", string zoneRenderPointerId = "", - string zoneRenderSchema = "") + string zoneRenderSchema = "", + string inputCapabilityPointerId = "") { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -342,6 +344,7 @@ namespace GameCult.Eve.UnityScene EntityViewSchema = entityViewSchema ?? ""; ZoneRenderPointerId = zoneRenderPointerId ?? ""; ZoneRenderSchema = zoneRenderSchema ?? ""; + InputCapabilityPointerId = inputCapabilityPointerId ?? ""; } public string WorldRootId { get; } @@ -356,6 +359,8 @@ namespace GameCult.Eve.UnityScene public string ZoneRenderSchema { get; } + public string InputCapabilityPointerId { get; } + public string AssetManifest { get; } public string InputProfile { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 340d752..c0cae75 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -338,11 +338,25 @@ namespace GameCult.Eve.UnityScene.Tests [Test] public void PlayableWorldRuntimeComposesProviderDocumentsAssetsReceiptsAndSceneSink() { - var surfaceDocuments = new FakeProviderSurfaceDocumentSource(new EveUnitySceneProviderSurfaceDocument( - PlayableArpgDocument(), - Advertisement("aetheria.daemon.game"), - "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", - 1)); + var surfaceDocuments = new FakeProviderSurfaceDocumentSource( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + currentInputCapability: new EveInputCapabilityDocument + { + ProviderId = "aetheria", + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "weapon-group.2.fire", + Operation = "aetheria.daemon.commands.FireWeaponGroup", + Payload = new Dictionary { ["weaponGroup"] = "2" } + } + } + }); var assetDocuments = new FakeAssetManifestDocumentSource(new EveUnityPlayableWorldAssetManifestDocument( "cultmesh://aetheria/assets/manifest", new[] @@ -392,6 +406,14 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); + var actionIntent = runtime.SubmitActionIntent("player-vanguard", "weapon-group.2.fire"); + + Assert.That(commandSink.Submitted.Count, Is.EqualTo(2)); + Assert.That(commandSink.Submitted[1], Is.SameAs(actionIntent)); + Assert.That(actionIntent.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.commands.FireWeaponGroup")); + Assert.That(actionIntent.Payload.GetString("weaponGroup"), Is.EqualTo("2")); + Assert.That(actionIntent.Payload.GetString("actionId"), Is.EqualTo("weapon-group.2.fire")); + surfaceDocuments.Publish(new EveUnitySceneProviderSurfaceDocument( PlayableArpgDocument(includeRaider: false, playerPosition: "14,0,9"), Advertisement("aetheria.daemon.game"), @@ -442,6 +464,10 @@ namespace GameCult.Eve.UnityScene.Tests { new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 1 }, new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 12, ElementStride = 4, ElementCount = 1 } + }, + Identities = new[] + { + new EveEntitySoaIdentity { EntityIndex = 41, EntityId = "world:entity:41", Kind = "ship", Label = "Test ship", Controllable = true, AssetRef = "prefab.entity.player" } } }; var surfaceDocuments = new FakeProviderSurfaceDocumentSource( @@ -462,7 +488,7 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(presentation.ActiveEntities, Is.EqualTo(1)); Assert.That(runtime.LastPresentation!.ActiveEntities, Is.EqualTo(1)); - Assert.That(sceneSink.Upserts.Exists(entry => entry.entity.EntityId == "entity:41"), Is.True); + Assert.That(sceneSink.Upserts.Exists(entry => entry.entity.EntityId == "world:entity:41"), Is.True); } [Test] @@ -1592,14 +1618,17 @@ namespace GameCult.Eve.UnityScene.Tests private sealed class FakeProviderSurfaceDocumentSource : IEveUnitySceneProviderSurfaceDocumentSource, - IEveUnityEntitySoaViewDocumentSource + IEveUnityEntitySoaViewDocumentSource, + IEveUnityInputCapabilityDocumentSource { public FakeProviderSurfaceDocumentSource( EveUnitySceneProviderSurfaceDocument currentDocument, - EveEntitySoaViewDocument? currentEntityView = null) + EveEntitySoaViewDocument? currentEntityView = null, + EveInputCapabilityDocument? currentInputCapability = null) { CurrentDocument = currentDocument; CurrentEntityView = currentEntityView; + CurrentInputCapability = currentInputCapability; } public EveUnitySceneProviderSurfaceDocument CurrentDocument { get; private set; } @@ -1610,6 +1639,8 @@ namespace GameCult.Eve.UnityScene.Tests public event Action? EntityViewAvailable; + public EveInputCapabilityDocument? CurrentInputCapability { get; } + public void Publish(EveUnitySceneProviderSurfaceDocument document) { CurrentDocument = document; diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index d1b5185..b56f14f 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -1,6 +1,8 @@ param( [string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe", [string] $AetheriaRoot = "E:\Projects\Aetheria", + [string] $EveUnityRoot = "", + [string] $CultLibRoot = "E:\Projects\CultLib", [string] $ClientProject = "ReleaseConsumerProject", [int] $Port = 3076, [string] $OutputDirectory = "artifacts\aetheria-daemon", @@ -11,6 +13,7 @@ param( $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot +$resolvedEveUnityRoot = if ([string]::IsNullOrWhiteSpace($EveUnityRoot)) { $repoRoot } else { $EveUnityRoot } $projectRoot = $ClientProject $outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory } $resultsPath = Join-Path $outputRoot "results.xml" @@ -57,7 +60,7 @@ foreach ($ephemeralPath in @( } $import = Start-Process -FilePath "dotnet" -ArgumentList @( - "run", "--project", $importProject, "--", $AetheriaRoot, $statePath + "run", "--project", $importProject, "-p:EveUnityRoot=$resolvedEveUnityRoot", "-p:CultLibRoot=$CultLibRoot", "--", $AetheriaRoot, $statePath ) -PassThru -WindowStyle Hidden -Wait ` -RedirectStandardOutput (Join-Path $outputRoot "aetheria-import.log") ` -RedirectStandardError (Join-Path $outputRoot "aetheria-import.error.log") @@ -65,7 +68,7 @@ if ($import.ExitCode -ne 0) { throw "Aetheria state import failed with exit code $($import.ExitCode). See $outputRoot\aetheria-import.error.log" } $daemonBuild = Start-Process -FilePath "dotnet" -ArgumentList @( - "build", $daemonProject + "build", $daemonProject, "-p:EveUnityRoot=$resolvedEveUnityRoot", "-p:CultLibRoot=$CultLibRoot" ) -PassThru -WindowStyle Hidden -Wait ` -RedirectStandardOutput $daemonBuildLogPath ` -RedirectStandardError (Join-Path $outputRoot "aetheria-daemon-build.error.log") From 65f1534b9da2061339243e2a2875387356db8901 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 01:23:42 +0100 Subject: [PATCH 10/16] Make live world proof realtime and receipt-correlated --- .../PlayMode/GenericWorldCaptureTests.cs | 127 +++++++++++++----- .../Runtime/EveCultMeshSurfaceSelection.cs | 27 +++- .../EveUnityCultMeshLiveProviderTransport.cs | 15 ++- scripts/run-aetheria-daemon-world-witness.ps1 | 12 +- 4 files changed, 143 insertions(+), 38 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 5b3e278..ac11694 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -186,8 +186,11 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt movementReceipt = null; WitnessReceipt focusReceipt = null; WitnessReceipt actionReceipt = null; + EveUnityShotReceipt combatShot = null; + string combatActionId = ""; long initialVersion = 0; float movementDistance = 0f; + var commandReceipts = new Dictionary(StringComparer.Ordinal); try { @@ -199,7 +202,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests surfaceId, requiredSurfaceKind: "interactive-world", clientRuntimeId: $"eve-unity-test-{Guid.NewGuid():N}"); - var publicationDeadline = Time.realtimeSinceStartup + 10f; + var publicationDeadline = Time.realtimeSinceStartup + 30f; while (true) { var published = false; @@ -225,6 +228,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests null, provider, provider); + runtime.ReceiptAvailable += receipt => commandReceipts[receipt.CommandId] = receipt; var initial = runtime.Connect(); Assert.That(initial.ActiveEntities, Is.GreaterThan(0)); Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); @@ -241,67 +245,84 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var deadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < deadline && - (runtime.LastReceipt == null || runtime.ActiveVersion <= initialVersion || + (!commandReceipts.TryGetValue(request.CommandId, out var observedMovement) || + runtime.ActiveVersion < observedMovement.SourceVersion || Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(request.CommandId)); + Assert.That(commandReceipts.TryGetValue(request.CommandId, out var movementCommandReceipt), Is.True); Assert.That( - runtime.LastReceipt.State, + movementCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), - runtime.LastReceipt.Message); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(initialVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThan(initialVersion)); + movementCommandReceipt.Message); + Assert.That(movementCommandReceipt.SourceVersion, Is.GreaterThan(initialVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(movementCommandReceipt.SourceVersion)); movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); Assert.That(movementDistance, Is.GreaterThan(0.01f)); - movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt); + movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt); var movementVersion = runtime.ActiveVersion; var focus = runtime.SubmitFocusIntent(playerId); var focusDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < focusDeadline && - (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId || - runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + (!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) || + runtime.ActiveVersion < observedFocus.SourceVersion)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(focus.CommandId)); - Assert.That(runtime.LastReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled")); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(movementVersion)); - focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); + Assert.That(commandReceipts.TryGetValue(focus.CommandId, out var focusCommandReceipt), Is.True); + Assert.That(focusCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled")); + Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(movementVersion)); + focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt); var focusVersion = runtime.ActiveVersion; + var trajectoryRenderer = root.AddComponent(); + runtime.ShotAvailable += shot => + { + var playerIdentity = provider.CurrentEntityView?.Identities + .FirstOrDefault(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal)); + if (playerIdentity != null && shot.SourceEntityIndex == playerIdentity.EntityIndex) + { + combatShot = shot; + trajectoryRenderer.Present(shot); + } + }; Assert.That(provider.CurrentInputCapability, Is.Not.Null, "The pilot world did not advertise a typed input capability document."); Assert.That(provider.CurrentInputCapability.Actions, Is.Not.Empty, "The pilot input capability advertised no available actions."); var advertisedAction = provider.CurrentInputCapability.Actions - .FirstOrDefault(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) && - string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)) - ?? provider.CurrentInputCapability.Actions - .First(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); + .First(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); + combatActionId = advertisedAction.ActionId; var action = runtime.SubmitActionIntent(playerId, advertisedAction.ActionId); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && - (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId || - runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + (!commandReceipts.TryGetValue(action.CommandId, out var observedAction) || + runtime.ActiveVersion < observedAction.SourceVersion || combatShot == null)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(action.CommandId)); + Assert.That(commandReceipts.TryGetValue(action.CommandId, out var actionCommandReceipt), Is.True); Assert.That( - runtime.LastReceipt.State, + actionCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), - runtime.LastReceipt.Message); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(focusVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); - actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt); + actionCommandReceipt.Message); + Assert.That(actionCommandReceipt.SourceVersion, Is.GreaterThan(focusVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(actionCommandReceipt.SourceVersion)); + Assert.That(combatShot, Is.Not.Null, + $"The daemon accepted fire but published no shot receipt through Eve. Weapon state: {DescribeWeaponStates(provider)}"); + Assert.That(combatShot.ShotId, Is.Not.Empty); + var playerEntityIndex = provider.CurrentEntityView.Identities + .Single(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal)).EntityIndex; + Assert.That(combatShot.SourceEntityIndex, Is.EqualTo(playerEntityIndex)); + Assert.That(combatShot.TargetEntityIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(combatShot.PresentationKind, Is.Not.Empty); + Assert.That(trajectoryRenderer.ActiveTrajectoryCount, Is.GreaterThan(0)); + actionReceipt = WitnessReceipt.From("action", actionCommandReceipt); var light = lightObject.AddComponent(); light.type = LightType.Directional; @@ -350,7 +371,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movementDistance, movementReceipt, focusReceipt, - actionReceipt); + actionReceipt, + combatActionId, + combatShot); } finally { @@ -372,7 +395,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests float movementDistance, WitnessReceipt movement, WitnessReceipt targeting, - WitnessReceipt action) + WitnessReceipt action, + string combatActionId, + EveUnityShotReceipt combatShot) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; @@ -383,6 +408,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movement = movement != null, targeting = targeting != null, action = action != null, + combatShot = combatShot != null, + combatActionId = combatActionId ?? "", + shotId = combatShot?.ShotId ?? "", + shotOutcome = combatShot?.Outcome ?? "", + shotPresentationKind = combatShot?.PresentationKind ?? "", + shotHit = combatShot?.Hit ?? false, initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, @@ -400,6 +431,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool movement; public bool targeting; public bool action; + public bool combatShot; + public string combatActionId; + public string shotId; + public string shotOutcome; + public string shotPresentationKind; + public bool shotHit; public long initialVersion; public long finalVersion; public float movementDistance; @@ -436,6 +473,34 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests throw new InvalidOperationException($"Projected world has no entity marker '{entityId}'."); } + private static string DescribeWeaponStates(EveUnityCultMeshPlayableWorldProvider provider) + { + return string.Join("; ", Flatten(provider.CurrentDocument.SurfaceDocument.Surface.Root) + .Where(node => string.Equals(node.Kind, "weapon.state", StringComparison.Ordinal)) + .Select(node => string.Join(",", new[] + { + Prop(node, "behaviorKind"), + $"pending={Prop(node, "triggerPending")}", + $"firing={Prop(node, "firing")}", + $"charging={Prop(node, "charging")}", + $"charged={Prop(node, "charged")}", + $"lock={Prop(node, "lockProgress")}", + $"target={Prop(node, "targetEntityId")}", + $"refusal={Prop(node, "lastRefusalReason")}" + }))); + } + + private static string Prop(EveSurfaceComponent node, string key) => + node.Props.TryGetValue(key, out var value) ? value : ""; + + private static IEnumerable Flatten(EveSurfaceComponent root) + { + yield return root; + foreach (var child in root.Children) + foreach (var descendant in Flatten(child)) + yield return descendant; + } + private static EveUnitySceneProviderSurfaceDocument WorldDocument() { var entities = new[] diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs index 9c6f82f..6fc6948 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs @@ -48,9 +48,23 @@ namespace GameCult.Eve.Surface { if (mesh == null) throw new ArgumentNullException(nameof(mesh)); var resolved = request ?? new EveSurfaceRequest(); - var providers = await mesh.CollectionAsync(endpointId, cancellationToken) - .ConfigureAwait(false); - var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved); + IReadOnlyList providerDocuments; + if (string.IsNullOrWhiteSpace(resolved.ProviderId)) + { + var providers = await mesh.CollectionAsync(endpointId, cancellationToken) + .ConfigureAwait(false); + providerDocuments = await providers.LatestAsync().ConfigureAwait(false); + } + else + { + var provider = await mesh.DocumentAsync( + endpointId, + ProviderAdvertisementRecordRef(resolved.ProviderId), + cancellationToken) + .ConfigureAwait(false); + providerDocuments = new[] { await provider.LatestAsync().ConfigureAwait(false) }; + } + var match = Select(providerDocuments, resolved); IReadOnlyList plugins = Array.Empty(); if (match.Surface.RequiresPlugins.Count > 0) { @@ -133,5 +147,12 @@ namespace GameCult.Eve.Surface private static bool Matches(string expected, string actual) => string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal); + + public static string ProviderAdvertisementRecordRef(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)); + return $"eve:provider:{providerId.Trim()}"; + } } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index c74bff9..dcc0c1c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -56,6 +56,7 @@ namespace GameCult.Eve.UnityScene private bool _liveSubscriptionsOpen; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; + private string _assetCatalogPointer = ""; public EveUnityCultMeshLiveProviderTransport( string replicaPath, @@ -123,6 +124,8 @@ namespace GameCult.Eve.UnityScene PublishReceipt(receipt); else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; + else if (document is EveAssetCatalogDocument assetCatalog) + ApplyAssetCatalog(assetCatalog); } } @@ -274,8 +277,18 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) return; - var catalog = _mesh.ReadAsync(_endpointId, interaction.AssetManifestRecordRef) + if (string.Equals(_assetCatalogPointer, interaction.AssetManifestRecordRef, StringComparison.Ordinal)) + return; + + _assetCatalogPointer = interaction.AssetManifestRecordRef; + var document = _mesh.DocumentAsync(_endpointId, _assetCatalogPointer) .GetAwaiter().GetResult(); + ApplyAssetCatalog(document.LatestAsync().GetAwaiter().GetResult()); + _liveWatches.Add(document.Watch(catalog => _liveDocuments.Enqueue(catalog))); + } + + private void ApplyAssetCatalog(EveAssetCatalogDocument catalog) + { if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) return; diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index b56f14f..36561f0 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -101,12 +101,13 @@ $daemonArguments = @( "--client-cultmesh-host", "127.0.0.1", "--client-cultmesh-advertise-host", "127.0.0.1", "--client-cultmesh-port", $Port, - "--tick-interval-ms", 250, + "--tick-interval-ms", 20, "--fixed-delta-ms", 20, "--no-odin-announcements" ) $env:AETHERIA_TRACE_EVE_SNAPSHOTS = "1" $env:AETHERIA_TRACE_CLIENT_RUDP = "1" +$env:AETHERIA_TRACE_TICK_PHASES = "1" $daemon = Start-Process -FilePath "dotnet" -ArgumentList $daemonArguments -PassThru -WindowStyle Hidden ` -RedirectStandardOutput $daemonLogPath -RedirectStandardError (Join-Path $outputRoot "aetheria-daemon.error.log") Write-Host "Aetheria daemon PID: $($daemon.Id)" @@ -124,8 +125,8 @@ try { if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port. See $daemonLogPath" } $env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port" - Remove-Item Env:EVEUNITY_PROVIDER_ID -ErrorAction SilentlyContinue - Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue + $env:EVEUNITY_PROVIDER_ID = "aetheria.daemon" + $env:EVEUNITY_SURFACE_ID = "aetheria.pilot" $env:EVEUNITY_REPLICA_PATH = $replicaPath $env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath $env:EVEUNITY_ASSET_CACHE_PATH = $assetCachePath @@ -149,6 +150,11 @@ try { if (-not (Test-Path $capturePath) -or (Get-Item $capturePath).Length -lt 1024) { throw "Live world capture is missing: $capturePath" } if (-not (Test-Path $factsPath)) { throw "Live world witness facts are missing: $factsPath" } $facts = Get-Content -LiteralPath $factsPath -Raw | ConvertFrom-Json + if (-not $facts.combatShot -or [string]::IsNullOrWhiteSpace([string]$facts.combatActionId) -or + [string]::IsNullOrWhiteSpace([string]$facts.shotId) -or + [string]::IsNullOrWhiteSpace([string]$facts.shotPresentationKind)) { + throw "Live world witness did not prove an advertised weapon action and authoritative shot presentation." + } $releaseLock = Get-Content -LiteralPath (Join-Path $repoRoot "ReleaseConsumerProject\Packages\packages-lock.json") -Raw | ConvertFrom-Json $releasedPackageClient = $projectRoot -eq "ReleaseConsumerProject" $facts | Add-Member -NotePropertyName releasedPackageClient -NotePropertyValue $releasedPackageClient From 3fee35512c261f79639587a51af23bd8f3900457 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 04:20:12 +0100 Subject: [PATCH 11/16] Lower live Aetheria world through CultMesh --- .../PlayMode/GenericWorldCaptureTests.cs | 83 +++++++- .../Runtime/EveCultMeshSurfaceSelection.cs | 11 +- .../Runtime/EveEntitySoaViewDocument.cs | 2 + .../EveUnityCultMeshLiveProviderTransport.cs | 191 ++++++++++++++++-- .../EveUnityCultMeshProviderDiscovery.cs | 4 +- .../Runtime/EveUnityEntitySoaView.cs | 71 ++++--- .../EveUnityPlayableWorldLiveClient.cs | 7 +- .../Runtime/EveUnityPlayableWorldRuntime.cs | 3 +- 8 files changed, 302 insertions(+), 70 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index ac11694..1e10481 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -230,11 +230,30 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests provider); runtime.ReceiptAvailable += receipt => commandReceipts[receipt.CommandId] = receipt; var initial = runtime.Connect(); + var entityDeadline = Time.realtimeSinceStartup + 12f; + while (initial.ActiveEntities == 0 && Time.realtimeSinceStartup < entityDeadline) + { + yield return new WaitForSecondsRealtime(0.1f); + initial = runtime.Refresh(); + } Assert.That(initial.ActiveEntities, Is.GreaterThan(0)); Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); var playerId = runtime.ActiveWorld.PlayerEntityId; var marker = FindMarker(root, playerId); + var realtimeAction = provider.CurrentInputCapability?.Actions + .FirstOrDefault(action => string.Equals(action.ActionId, "simulation.rate.realtime", StringComparison.Ordinal)); + Assert.That(realtimeAction, Is.Not.Null, "Terminus did not advertise its user-controlled simulation clock."); + var resume = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId); + var resumeDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < resumeDeadline && + !commandReceipts.TryGetValue(resume.CommandId, out _)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(resume.CommandId, out var resumeReceipt), Is.True); + Assert.That(resumeReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeReceipt.Message); Assert.That( marker.GetComponentsInChildren(includeInactive: true).Length, Is.GreaterThan(1), @@ -264,21 +283,43 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(movementDistance, Is.GreaterThan(0.01f)); movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt); - var movementVersion = runtime.ActiveVersion; - var focus = runtime.SubmitFocusIntent(playerId); - var focusDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < focusDeadline && - (!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) || - runtime.ActiveVersion < observedFocus.SourceVersion)) + var stop = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); + var stopDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < stopDeadline && + (!commandReceipts.TryGetValue(stop.CommandId, out var observedStop) || + runtime.ActiveVersion < observedStop.SourceVersion)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(commandReceipts.TryGetValue(focus.CommandId, out var focusCommandReceipt), Is.True); - Assert.That(focusCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled")); - Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(movementVersion)); - focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt); + Assert.That(commandReceipts.TryGetValue(stop.CommandId, out var stopCommandReceipt), Is.True); + Assert.That( + stopCommandReceipt.State, + Is.EqualTo("accepted").Or.EqualTo("reconciled"), + stopCommandReceipt.Message); + var pauseAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "simulation.pause", StringComparison.Ordinal)); + var pause = runtime.SubmitActionIntent(playerId, pauseAction.ActionId); + var pauseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < pauseDeadline && + (!commandReceipts.TryGetValue(pause.CommandId, out var observedPause) || + runtime.ActiveVersion < observedPause.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(pause.CommandId, out var pauseReceipt), Is.True); + Assert.That(pauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), pauseReceipt.Message); + + var movementVersion = runtime.ActiveVersion; + var targetMarker = root.GetComponentsInChildren() + .Where(candidate => candidate.Selectable && !candidate.Controllable && + string.Equals(candidate.EntityKind, "ship", StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate.Faction, marker.Faction, StringComparison.Ordinal)) + .OrderBy(candidate => Vector3.Distance(candidate.transform.position, marker.transform.position)) + .First(); + var focus = runtime.SubmitTargetIntent(playerId, targetMarker.EntityId); var focusVersion = runtime.ActiveVersion; var trajectoryRenderer = root.AddComponent(); runtime.ShotAvailable += shot => @@ -298,14 +339,34 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); combatActionId = advertisedAction.ActionId; var action = runtime.SubmitActionIntent(playerId, advertisedAction.ActionId); + var resumeCombat = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId); + var resumeCombatDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < resumeCombatDeadline && + (!commandReceipts.TryGetValue(resumeCombat.CommandId, out var observedResumeCombat) || + runtime.ActiveVersion < observedResumeCombat.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(resumeCombat.CommandId, out var resumeCombatReceipt), Is.True); + Assert.That(resumeCombatReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeCombatReceipt.Message); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && - (!commandReceipts.TryGetValue(action.CommandId, out var observedAction) || + (!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) || + runtime.ActiveVersion < observedFocus.SourceVersion || + !commandReceipts.TryGetValue(action.CommandId, out var observedAction) || runtime.ActiveVersion < observedAction.SourceVersion || combatShot == null)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } + Assert.That(commandReceipts.TryGetValue(focus.CommandId, out var focusCommandReceipt), Is.True); + Assert.That( + focusCommandReceipt.State, + Is.EqualTo("accepted").Or.EqualTo("reconciled"), + focusCommandReceipt.Message); + Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(movementVersion)); + focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt); Assert.That(commandReceipts.TryGetValue(action.CommandId, out var actionCommandReceipt), Is.True); Assert.That( actionCommandReceipt.State, diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs index 6fc6948..3af0c86 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs @@ -44,26 +44,32 @@ namespace GameCult.Eve.Surface this CultMeshClient mesh, string endpointId, EveSurfaceRequest? request = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Action? observeStage = null) { if (mesh == null) throw new ArgumentNullException(nameof(mesh)); var resolved = request ?? new EveSurfaceRequest(); IReadOnlyList providerDocuments; if (string.IsNullOrWhiteSpace(resolved.ProviderId)) { + observeStage?.Invoke("provider-collection-opening"); var providers = await mesh.CollectionAsync(endpointId, cancellationToken) .ConfigureAwait(false); + observeStage?.Invoke("provider-collection-ready"); providerDocuments = await providers.LatestAsync().ConfigureAwait(false); } else { + observeStage?.Invoke("provider-document-opening"); var provider = await mesh.DocumentAsync( endpointId, ProviderAdvertisementRecordRef(resolved.ProviderId), cancellationToken) .ConfigureAwait(false); + observeStage?.Invoke("provider-document-ready"); providerDocuments = new[] { await provider.LatestAsync().ConfigureAwait(false) }; } + observeStage?.Invoke("provider-snapshot-ready"); var match = Select(providerDocuments, resolved); IReadOnlyList plugins = Array.Empty(); if (match.Surface.RequiresPlugins.Count > 0) @@ -71,12 +77,15 @@ namespace GameCult.Eve.Surface var advertisements = await mesh.CollectionAsync(endpointId, cancellationToken) .ConfigureAwait(false); plugins = ResolvePlugins(match.Surface, await advertisements.LatestAsync().ConfigureAwait(false)); + observeStage?.Invoke("plugin-snapshot-ready"); } + observeStage?.Invoke("surface-document-opening"); var surface = await mesh.DocumentAsync( endpointId, match.Surface.RecordRef, cancellationToken) .ConfigureAwait(false); + observeStage?.Invoke("surface-document-ready"); return new EveSurfaceSelection(match.Provider, match.Surface, surface, plugins); } diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs index efe1caf..22d55aa 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs @@ -1,6 +1,7 @@ using System; using GameCult.Caching; using MessagePack; +using GameCult.Mesh; #nullable enable @@ -25,6 +26,7 @@ namespace GameCult.Eve.Surface [Key(10)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty(); [Key(11)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty(); [Key(12)] public EveEntitySoaIdentity[] Identities { get; set; } = Array.Empty(); + [Key(13)] public CultMeshBodyDescriptor? Body { get; set; } } [MessagePackObject] diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index dcc0c1c..04cbeb1 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -51,12 +51,15 @@ namespace GameCult.Eve.UnityScene private readonly List _assetBundles = new List(); private readonly Dictionary _cameraCullingMasks = new Dictionary(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); + private readonly CancellationTokenSource _backgroundCancellation = new CancellationTokenSource(); private CultNetDocumentRegistry? _networkRegistry; private readonly List _liveWatches = new List(); private bool _liveSubscriptionsOpen; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; private string _assetCatalogPointer = ""; + private Task? _assetCatalogTask; + private EveEntitySoaViewDocument? _deferredEntityView; public EveUnityCultMeshLiveProviderTransport( string replicaPath, @@ -117,7 +120,12 @@ namespace GameCult.Eve.UnityScene while (_liveDocuments.TryDequeue(out var document)) { if (document is EveEntitySoaViewDocument entityView) - EntityViewAvailable?.Invoke(entityView); + { + if (!string.IsNullOrWhiteSpace(_assetCatalogPointer) && CurrentAssetCatalogVersion < 0) + _deferredEntityView = entityView; + else + EntityViewAvailable?.Invoke(entityView); + } else if (document is EveSurfaceDocument surface) PublishSurface(surface); else if (document is EveCommandReceiptDocument receipt) @@ -126,6 +134,10 @@ namespace GameCult.Eve.UnityScene CurrentInputCapability = inputCapability; else if (document is EveAssetCatalogDocument assetCatalog) ApplyAssetCatalog(assetCatalog); + else if (document is PreparedAssetCatalog preparedAssetCatalog) + ApplyPreparedAssetCatalog(preparedAssetCatalog); + else if (document is BackgroundFailure failure) + throw new InvalidOperationException(failure.Operation + " failed.", failure.Error); } } @@ -148,6 +160,12 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException("Eve command invocations require an idempotency key."); var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); + if (string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef)) + throw new InvalidOperationException("The provider advertisement does not publish a receipt record reference."); + var receiptKey = ChildRecordKey(interaction.ReceiptRecordRef, commandId); + var receipt = _mesh.DocumentAsync(_endpointId, receiptKey) + .GetAwaiter().GetResult(); + _liveWatches.Add(receipt.Watch(value => _liveDocuments.Enqueue(value))); var message = _networkRegistry!.CreateRawDocumentPutMessage( $"eve-unity-{commandId}", new CultRecordHandle(new CultRecordKey(recordKey)), @@ -161,10 +179,44 @@ namespace GameCult.Eve.UnityScene message.ShardEpoch = 1; _documentSession!.SendCultNet(message); _pendingCommandIds.Add(commandId); + _ = AwaitReceiptAsync(receiptKey, commandId, _backgroundCancellation.Token); + } + + private async Task AwaitReceiptAsync(string receiptKey, string commandId, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 60 && !cancellationToken.IsCancellationRequested; attempt++) + { + try + { + var receipt = await _mesh.ReadAsync( + _endpointId, + receiptKey, + TimeSpan.FromSeconds(2), + cancellationToken) + .ConfigureAwait(false); + if (string.Equals(receipt.CommandId, commandId, StringComparison.Ordinal)) + { + _liveDocuments.Enqueue(receipt); + return; + } + } + catch (InvalidOperationException) + { + } + try + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + } } public void Dispose() { + _backgroundCancellation.Cancel(); foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); _assetBundles.Clear(); _prefabs.Clear(); @@ -176,6 +228,7 @@ namespace GameCult.Eve.UnityScene _networkRegistry = null; _pendingCommandIds.Clear(); _publishedReceiptIds.Clear(); + _backgroundCancellation.Dispose(); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) @@ -264,11 +317,6 @@ namespace GameCult.Eve.UnityScene CurrentInputCapability = inputs.LatestAsync().GetAwaiter().GetResult(); _liveWatches.Add(inputs.Watch(document => _liveDocuments.Enqueue(document))); } - var receipts = _mesh.CollectionAsync(_endpointId).GetAwaiter().GetResult(); - _liveWatches.Add(receipts.WatchChanges(change => - { - if (change.Document != null) _liveDocuments.Enqueue(change.Document); - })); } private void RefreshAssetCatalog() @@ -281,16 +329,50 @@ namespace GameCult.Eve.UnityScene return; _assetCatalogPointer = interaction.AssetManifestRecordRef; - var document = _mesh.DocumentAsync(_endpointId, _assetCatalogPointer) - .GetAwaiter().GetResult(); - ApplyAssetCatalog(document.LatestAsync().GetAwaiter().GetResult()); - _liveWatches.Add(document.Watch(catalog => _liveDocuments.Enqueue(catalog))); + var platform = CurrentBundlePlatform(); + var cacheRoot = AssetCacheRoot(); + _assetCatalogTask = ObserveBackgroundAsync( + OpenAssetCatalogAsync(_assetCatalogPointer, platform, cacheRoot), + "CultMesh asset catalog preparation"); + } + + private async Task ObserveBackgroundAsync(Task operation, string name) + { + try + { + await operation.ConfigureAwait(false); + } + catch (Exception error) + { + _liveDocuments.Enqueue(new BackgroundFailure(name, error)); + } + } + + private async Task OpenAssetCatalogAsync(string pointer, string platform, string cacheRoot) + { + var document = await _mesh.DocumentAsync(_endpointId, pointer).ConfigureAwait(false); + var catalog = await document.LatestAsync().ConfigureAwait(false); + _liveDocuments.Enqueue(await PrepareAssetCatalogAsync(catalog, platform, cacheRoot).ConfigureAwait(false)); + _liveWatches.Add(document.Watch(next => + { + _ = Task.Run(async () => + _liveDocuments.Enqueue(await PrepareAssetCatalogAsync(next, platform, cacheRoot).ConfigureAwait(false))); + })); } private void ApplyAssetCatalog(EveAssetCatalogDocument catalog) { - if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) - return; + if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) return; + var platform = CurrentBundlePlatform(); + var cacheRoot = AssetCacheRoot(); + _assetCatalogTask = Task.Run(async () => + _liveDocuments.Enqueue(await PrepareAssetCatalogAsync(catalog, platform, cacheRoot).ConfigureAwait(false))); + } + + private void ApplyPreparedAssetCatalog(PreparedAssetCatalog prepared) + { + var catalog = prepared.Catalog; + if (catalog.Version == CurrentAssetCatalogVersion) return; foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); _assetBundles.Clear(); @@ -303,7 +385,7 @@ namespace GameCult.Eve.UnityScene Asset = asset, Variant = asset.Variants.FirstOrDefault(variant => string.Equals(variant.RuntimeId, "unity-scene", StringComparison.Ordinal) && - string.Equals(variant.Platform, CurrentBundlePlatform(), StringComparison.Ordinal)) + string.Equals(variant.Platform, prepared.Platform, StringComparison.Ordinal)) }) .Where(selection => selection.Variant != null) .ToArray(); @@ -311,10 +393,7 @@ namespace GameCult.Eve.UnityScene foreach (var group in selected.GroupBy(selection => selection.Variant!.Uri, StringComparer.Ordinal)) { var variant = group.First().Variant!; - var descriptor = _mesh.ReadAsync(_endpointId, variant.Uri) - .GetAwaiter().GetResult(); - var bytes = ReadCachedBundle(descriptor, variant); - VerifyBundle(bytes, variant); + var bytes = prepared.BundleBytes[variant.Uri]; var bundle = AssetBundle.LoadFromMemory(bytes); if (bundle == null) throw new InvalidOperationException($"Unity could not load provider asset bundle '{variant.Uri}'."); @@ -336,6 +415,34 @@ namespace GameCult.Eve.UnityScene } CurrentAssetCatalogVersion = catalog.Version; + if (_deferredEntityView != null) + { + var deferred = _deferredEntityView; + _deferredEntityView = null; + EntityViewAvailable?.Invoke(deferred); + } + } + + private async Task PrepareAssetCatalogAsync( + EveAssetCatalogDocument catalog, + string platform, + string cacheRoot) + { + var variants = catalog.Assets + .SelectMany(asset => asset.Variants) + .Where(variant => string.Equals(variant.RuntimeId, "unity-scene", StringComparison.Ordinal) && + string.Equals(variant.Platform, platform, StringComparison.Ordinal)) + .GroupBy(variant => variant.Uri, StringComparer.Ordinal) + .Select(group => group.First()) + .ToArray(); + var bundles = new Dictionary(StringComparer.Ordinal); + foreach (var variant in variants) + { + var descriptor = await _mesh.ReadAsync(_endpointId, variant.Uri) + .ConfigureAwait(false); + bundles[variant.Uri] = await ReadCachedBundleAsync(descriptor, variant, cacheRoot).ConfigureAwait(false); + } + return new PreparedAssetCatalog(catalog, platform, bundles); } private void ReadCameraPolicies(IEnumerable variants) @@ -373,6 +480,9 @@ namespace GameCult.Eve.UnityScene } private byte[] ReadBundle(CultMeshCdnArtifactManifest manifest) + => ReadBundleAsync(manifest).GetAwaiter().GetResult(); + + private async Task ReadBundleAsync(CultMeshCdnArtifactManifest manifest) { var bytes = new byte[checked((int)manifest.SizeBytes)]; var references = manifest.Chunks.OrderBy(chunk => chunk.Offset).ToArray(); @@ -406,15 +516,18 @@ namespace GameCult.Eve.UnityScene concurrency.Release(); } }).ToArray(); - Task.WhenAll(downloads).GetAwaiter().GetResult(); + await Task.WhenAll(downloads).ConfigureAwait(false); return bytes; } private byte[] ReadCachedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant) + => ReadCachedBundleAsync(manifest, variant, AssetCacheRoot()).GetAwaiter().GetResult(); + + private async Task ReadCachedBundleAsync( + CultMeshCdnArtifactManifest manifest, + EveAssetVariant variant, + string cacheRoot) { - var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH"); - if (string.IsNullOrWhiteSpace(cacheRoot)) - cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets"); Directory.CreateDirectory(cacheRoot); var hash = variant.ContentHash.StartsWith("sha256:", StringComparison.Ordinal) ? variant.ContentHash.Substring("sha256:".Length) @@ -427,7 +540,7 @@ namespace GameCult.Eve.UnityScene return cached; } - var bytes = ReadBundle(manifest); + var bytes = await ReadBundleAsync(manifest).ConfigureAwait(false); VerifyBundle(bytes, variant); var temporaryPath = cachePath + ".tmp"; File.WriteAllBytes(temporaryPath, bytes); @@ -436,6 +549,40 @@ namespace GameCult.Eve.UnityScene return bytes; } + private static string AssetCacheRoot() + { + var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH"); + return string.IsNullOrWhiteSpace(cacheRoot) + ? Path.Combine(Application.persistentDataPath, "EveUnity", "assets") + : cacheRoot; + } + + private sealed class PreparedAssetCatalog + { + public PreparedAssetCatalog(EveAssetCatalogDocument catalog, string platform, Dictionary bundleBytes) + { + Catalog = catalog; + Platform = platform; + BundleBytes = bundleBytes; + } + + public EveAssetCatalogDocument Catalog { get; } + public string Platform { get; } + public Dictionary BundleBytes { get; } + } + + private sealed class BackgroundFailure + { + public BackgroundFailure(string operation, Exception error) + { + Operation = operation; + Error = error; + } + + public string Operation { get; } + public Exception Error { get; } + } + private static void VerifyBundle(byte[] bytes, EveAssetVariant variant) { if (bytes.LongLength != variant.SizeBytes) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index 8354c2d..fe34dba 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Mesh; +using UnityEngine; #nullable enable @@ -60,7 +61,8 @@ namespace GameCult.Eve.UnityScene SurfaceKind = surfaceKind, LoweringTarget = "unity-scene" }, - cancellationToken).ConfigureAwait(false); + cancellationToken, + stage => Debug.Log($"EveUnity CultMesh discovery: {stage}")).ConfigureAwait(false); return new EveUnityCultMeshProviderSelection(endpointId, mesh, surface); } catch diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs index f1498ef..db2fd6d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.IO.MemoryMappedFiles; using System.Linq; using GameCult.Eve.Surface; +using GameCult.Mesh; using UnityEngine; #nullable enable @@ -11,25 +11,14 @@ namespace GameCult.Eve.UnityScene { public sealed class EveUnityEntitySoaView : IDisposable { - private readonly Dictionary _buffers = - new Dictionary(StringComparer.Ordinal); + private readonly ICultMeshBodyReadLease _body; private readonly Dictionary _columns; - private EveUnityEntitySoaView(EveEntitySoaViewDocument document) + private EveUnityEntitySoaView(EveEntitySoaViewDocument document, ICultMeshBodyReadLease body) { Document = document; + _body = body; _columns = document.Columns.ToDictionary(column => column.Semantic, StringComparer.Ordinal); - foreach (var buffer in document.Buffers) - { - if (!string.Equals(buffer.Backend, "memory_mapped_file", StringComparison.Ordinal)) - throw new NotSupportedException($"EveUnity does not support entity SoA backend '{buffer.Backend}'."); - var mapped = MemoryMappedFile.OpenExisting(buffer.Location, MemoryMappedFileRights.Read); - _buffers.Add(buffer.BufferId, mapped.CreateViewAccessor( - buffer.ByteOffset, - buffer.ByteLength, - MemoryMappedFileAccess.Read)); - mapped.Dispose(); - } } public EveEntitySoaViewDocument Document { get; } @@ -41,39 +30,51 @@ namespace GameCult.Eve.UnityScene if (document == null) throw new ArgumentNullException(nameof(document)); if (!string.Equals(document.Schema, EveEntitySoaViewDocument.SchemaId, StringComparison.Ordinal)) throw new ArgumentException($"Expected {EveEntitySoaViewDocument.SchemaId}, got '{document.Schema}'.", nameof(document)); - return new EveUnityEntitySoaView(document); + if (document.Body == null) + throw new InvalidOperationException("Eve entity SoA view did not advertise a CultMesh body."); + var request = new CultMeshBodyValidationRequest + { + BodyId = document.Body.BodyId, + SchemaId = document.Body.SchemaId, + LayoutVersion = document.Body.LayoutVersion, + ProducerEpoch = document.Body.ProducerEpoch, + AccessMode = CultMeshBodyAccessMode.ReadOnly, + NowUtc = DateTimeOffset.UtcNow + }; + var body = new CultMeshSharedMemoryBodyAdapter().OpenReadOnly(document.Body, request); + return new EveUnityEntitySoaView(document, body); } public bool TryReadVector3(string semantic, int index, out Vector3 value) { value = default; - if (!TryColumn(semantic, index, "float3", 12, out var column, out var accessor)) return false; + if (!TryColumn(semantic, index, "float3", 12, out var column)) return false; var offset = column.ByteOffset + (long)index * column.ElementStride; - value = new Vector3(accessor.ReadSingle(offset), accessor.ReadSingle(offset + 4), accessor.ReadSingle(offset + 8)); + value = new Vector3(_body.ReadSingle(offset), _body.ReadSingle(offset + 4), _body.ReadSingle(offset + 8)); return true; } public bool TryReadFloat(string semantic, int index, out float value) { value = default; - if (!TryColumn(semantic, index, "float32", 4, out var column, out var accessor)) return false; - value = accessor.ReadSingle(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "float32", 4, out var column)) return false; + value = _body.ReadSingle(column.ByteOffset + (long)index * column.ElementStride); return true; } public bool TryReadInt32(string semantic, int index, out int value) { value = default; - if (!TryColumn(semantic, index, "int32", 4, out var column, out var accessor)) return false; - value = accessor.ReadInt32(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "int32", 4, out var column)) return false; + value = _body.ReadInt32(column.ByteOffset + (long)index * column.ElementStride); return true; } public bool TryReadUInt32(string semantic, int index, out uint value) { value = default; - if (!TryColumn(semantic, index, "uint32", 4, out var column, out var accessor)) return false; - value = accessor.ReadUInt32(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "uint32", 4, out var column)) return false; + value = unchecked((uint)_body.ReadInt32(column.ByteOffset + (long)index * column.ElementStride)); return true; } @@ -84,15 +85,14 @@ namespace GameCult.Eve.UnityScene public bool TryReadByte(string semantic, int index, out byte value) { value = default; - if (!TryColumn(semantic, index, "uint8", 1, out var column, out var accessor)) return false; - value = accessor.ReadByte(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "uint8", 1, out var column)) return false; + value = _body.ReadByte(column.ByteOffset + (long)index * column.ElementStride); return true; } public void Dispose() { - foreach (var accessor in _buffers.Values) accessor.Dispose(); - _buffers.Clear(); + _body.Dispose(); } private bool TryColumn( @@ -100,15 +100,22 @@ namespace GameCult.Eve.UnityScene int index, string scalarType, int minimumStride, - out EveEntitySoaColumn column, - out MemoryMappedViewAccessor accessor) + out EveEntitySoaColumn column) { column = null!; - accessor = null!; if (index < 0 || !_columns.TryGetValue(semantic ?? "", out column)) return false; if (index >= column.ElementCount || column.ElementStride < minimumStride) return false; if (!string.Equals(column.ScalarType, scalarType, StringComparison.Ordinal)) return false; - return _buffers.TryGetValue(column.BufferId, out accessor); + return DocumentBufferContains(column); + } + + private bool DocumentBufferContains(EveEntitySoaColumn column) + { + var buffer = Document.Buffers.FirstOrDefault(candidate => + string.Equals(candidate.BufferId, column.BufferId, StringComparison.Ordinal)); + if (buffer == null || buffer.ByteOffset != 0 || Document.Body == null) return false; + var columnEnd = column.ByteOffset + (long)column.ElementStride * column.ElementCount; + return column.ByteOffset >= 0 && columnEnd <= Document.Body.ByteSize; } } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index e1ab229..222d9ad 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -11,6 +11,7 @@ namespace GameCult.Eve.UnityScene private readonly EveUnitySceneProviderConnection _connection; private readonly EveUnityPlayableWorldPresenter _presenter; private readonly IEveUnitySceneCommandReceiptSource? _receiptSource; + private readonly Func? _visibleStateVersion; private readonly EveUnityFeedbackPresenter _feedback = new EveUnityFeedbackPresenter(); private readonly EveUnityShotReceiptPresenter _shots = new EveUnityShotReceiptPresenter(); private readonly List _pendingReceipts = @@ -21,11 +22,13 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldLiveClient( EveUnitySceneProviderConnection connection, EveUnityPlayableWorldPresenter presenter, - IEveUnitySceneCommandReceiptSource? receiptSource = null) + IEveUnitySceneCommandReceiptSource? receiptSource = null, + Func? visibleStateVersion = null) { _connection = connection ?? throw new ArgumentNullException(nameof(connection)); _presenter = presenter ?? throw new ArgumentNullException(nameof(presenter)); _receiptSource = receiptSource; + _visibleStateVersion = visibleStateVersion; } public EveUnitySceneProjection? ActiveProjection => _connection.ActiveProjection; @@ -36,7 +39,7 @@ namespace GameCult.Eve.UnityScene public EveUnitySceneCommandReceipt? LastReceipt { get; private set; } - public long ActiveVersion => _connection.ActiveVersion; + public long ActiveVersion => Math.Max(_connection.ActiveVersion, _visibleStateVersion?.Invoke() ?? 0); public string SourcePointer => _connection.SourcePointer; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs index 82f8bb2..393a7ba 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -41,7 +41,8 @@ namespace GameCult.Eve.UnityScene _client = new EveUnityPlayableWorldLiveClient( _connection, new EveUnityPlayableWorldPresenter(sceneSink, assetResolver ?? new EveUnityAssetRefResolver()), - receiptSource); + receiptSource, + () => _entityViews?.CurrentEntityView?.FrameId ?? 0); if (assetManifestDocuments != null) _assetManifestSource = new EveUnityPlayableWorldAssetManifestDocumentSource(assetManifestDocuments); From 7029a19976c5ba07ec582a569a88ddfba4aa5d02 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 04:43:37 +0100 Subject: [PATCH 12/16] Target hostile semantic contacts in Unity witness --- .../Tests/PlayMode/GenericWorldCaptureTests.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 1e10481..81764e7 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -313,12 +313,19 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(pauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), pauseReceipt.Message); var movementVersion = runtime.ActiveVersion; - var targetMarker = root.GetComponentsInChildren() + var visibleMarkers = root.GetComponentsInChildren(); + var hostileTargets = visibleMarkers .Where(candidate => candidate.Selectable && !candidate.Controllable && - string.Equals(candidate.EntityKind, "ship", StringComparison.OrdinalIgnoreCase) && !string.Equals(candidate.Faction, marker.Faction, StringComparison.Ordinal)) .OrderBy(candidate => Vector3.Distance(candidate.transform.position, marker.transform.position)) - .First(); + .ToArray(); + Assert.That( + hostileTargets, + Is.Not.Empty, + "No selectable hostile contact was lowered. Visible entities: " + + string.Join(", ", visibleMarkers.Select(candidate => + $"{candidate.EntityId}[kind={candidate.EntityKind},faction={candidate.Faction},selectable={candidate.Selectable}]"))); + var targetMarker = hostileTargets[0]; var focus = runtime.SubmitTargetIntent(playerId, targetMarker.EntityId); var focusVersion = runtime.ActiveVersion; var trajectoryRenderer = root.AddComponent(); From 65bab4d2cda44c8f682b69f53fa84581439fe8c4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 04:50:54 +0100 Subject: [PATCH 13/16] Prove daemon pickup collection in generic Unity --- .../PlayMode/GenericWorldCaptureTests.cs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 81764e7..19194cc 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -188,6 +188,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt actionReceipt = null; EveUnityShotReceipt combatShot = null; string combatActionId = ""; + string collectedPickupId = ""; + bool pickupCollected = false; long initialVersion = 0; float movementDistance = 0f; var commandReceipts = new Dictionary(StringComparer.Ordinal); @@ -241,6 +243,14 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var playerId = runtime.ActiveWorld.PlayerEntityId; var marker = FindMarker(root, playerId); + var seededPickup = root.GetComponentsInChildren() + .SingleOrDefault(candidate => string.Equals(candidate.EntityKind, "pickup", StringComparison.Ordinal)); + Assert.That(seededPickup, Is.Not.Null, "The daemon-authored salvage pickup was not lowered through the Eve entity view."); + collectedPickupId = seededPickup.EntityId; + Assert.That( + seededPickup.GetComponentsInChildren(includeInactive: true).Length, + Is.GreaterThan(1), + "The generic client did not instantiate the provider-authored pickup asset."); var realtimeAction = provider.CurrentInputCapability?.Actions .FirstOrDefault(action => string.Equals(action.ActionId, "simulation.rate.realtime", StringComparison.Ordinal)); Assert.That(realtimeAction, Is.Not.Null, "Terminus did not advertise its user-controlled simulation clock."); @@ -281,6 +291,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(movementCommandReceipt.SourceVersion)); movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); Assert.That(movementDistance, Is.GreaterThan(0.01f)); + var pickupDeadline = Time.realtimeSinceStartup + 8f; + while (Time.realtimeSinceStartup < pickupDeadline && + root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + pickupCollected = !root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + Assert.That(pickupCollected, Is.True, "Ymir/daemon simulation did not collect the seeded pickup when the player crossed it."); movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt); var stop = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); @@ -441,7 +462,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests focusReceipt, actionReceipt, combatActionId, - combatShot); + combatShot, + collectedPickupId, + pickupCollected); } finally { @@ -465,7 +488,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt targeting, WitnessReceipt action, string combatActionId, - EveUnityShotReceipt combatShot) + EveUnityShotReceipt combatShot, + string collectedPickupId, + bool pickupCollected) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; @@ -482,6 +507,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests shotOutcome = combatShot?.Outcome ?? "", shotPresentationKind = combatShot?.PresentationKind ?? "", shotHit = combatShot?.Hit ?? false, + pickupVisible = !string.IsNullOrWhiteSpace(collectedPickupId), + pickupCollected = pickupCollected, + pickupId = collectedPickupId ?? "", initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, @@ -505,6 +533,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public string shotOutcome; public string shotPresentationKind; public bool shotHit; + public bool pickupVisible; + public bool pickupCollected; + public string pickupId; public long initialVersion; public long finalVersion; public float movementDistance; From e44fcb0efa5462b01b55d16926dff01d2f539e06 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 05:33:45 +0100 Subject: [PATCH 14/16] Lower generic docked world presentation --- .../PlayMode/GenericWorldCaptureTests.cs | 83 ++++++++++++++++--- ...veUnityGameObjectPlayableWorldSceneSink.cs | 19 +++++ .../Runtime/EveUnityPlayableWorldCameraRig.cs | 22 ++--- .../EveUnityPlayableWorldInputDriver.cs | 2 +- .../Runtime/EveUnitySceneSurfaceLowerer.cs | 26 +++++- .../EveUnitySceneSurfaceLowererTests.cs | 8 ++ 6 files changed, 136 insertions(+), 24 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 19194cc..a3d60a7 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -190,6 +190,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests string combatActionId = ""; string collectedPickupId = ""; bool pickupCollected = false; + bool docked = false; + bool undocked = false; + string stationObservedHostileId = ""; long initialVersion = 0; float movementDistance = 0f; var commandReceipts = new Dictionary(StringComparer.Ordinal); @@ -251,6 +254,11 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests seededPickup.GetComponentsInChildren(includeInactive: true).Length, Is.GreaterThan(1), "The generic client did not instantiate the provider-authored pickup asset."); + var dockAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "pilot.dock", StringComparison.Ordinal)); + var undockAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "pilot.undock", StringComparison.Ordinal)); + var dock = runtime.SubmitActionIntent(playerId, dockAction.ActionId); var realtimeAction = provider.CurrentInputCapability?.Actions .FirstOrDefault(action => string.Equals(action.ActionId, "simulation.rate.realtime", StringComparison.Ordinal)); Assert.That(realtimeAction, Is.Not.Null, "Terminus did not advertise its user-controlled simulation clock."); @@ -268,6 +276,49 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests marker.GetComponentsInChildren(includeInactive: true).Length, Is.GreaterThan(1), "The generic client substituted its primitive fallback instead of the provider-authored AssetBundle prefab."); + var dockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < dockDeadline && + (!commandReceipts.TryGetValue(dock.CommandId, out var observedDock) || + runtime.ActiveVersion < observedDock.SourceVersion || + !string.Equals(runtime.ActiveWorld.PresentationMode, "docked", StringComparison.Ordinal))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(dock.CommandId, out var dockReceipt), Is.True); + Assert.That(dockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), dockReceipt.Message); + Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("docked")); + Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.Not.Empty.And.Not.EqualTo(playerId)); + Assert.That(FindMarker(root, runtime.ActiveWorld.CameraTargetEntityId), Is.Not.Null, + "The daemon did not include the authoritative dock parent in the docked SoA view."); + stationObservedHostileId = provider.CurrentEntityView.Identities + .FirstOrDefault(identity => string.Equals(identity.Faction, "raider", StringComparison.Ordinal))?.EntityId ?? ""; + Assert.That(stationObservedHostileId, Is.Not.Empty, + "The docked pilot did not inherit the station's sensor-derived hostile contacts."); + Assert.That(runtime.ActiveWorld.SubjectVisible, Is.False); + Assert.That(runtime.ActiveWorld.MovementEnabled, Is.False); + Assert.That(FindMarker(root, playerId, includeInactive: true).gameObject.activeSelf, Is.False); + docked = true; + + var undock = runtime.SubmitActionIntent(playerId, undockAction.ActionId); + var undockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < undockDeadline && + (!commandReceipts.TryGetValue(undock.CommandId, out var observedUndock) || + runtime.ActiveVersion < observedUndock.SourceVersion || + !string.Equals(runtime.ActiveWorld.PresentationMode, "world", StringComparison.Ordinal))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(undock.CommandId, out var undockReceipt), Is.True); + Assert.That(undockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), undockReceipt.Message); + Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("world")); + Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.EqualTo(playerId)); + Assert.That(runtime.ActiveWorld.SubjectVisible, Is.True); + Assert.That(runtime.ActiveWorld.MovementEnabled, Is.True); + Assert.That(FindMarker(root, playerId).gameObject.activeSelf, Is.True); + undocked = true; + var initialPosition = marker.transform.position; initialVersion = runtime.ActiveVersion; var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); @@ -337,17 +388,18 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var visibleMarkers = root.GetComponentsInChildren(); var hostileTargets = visibleMarkers .Where(candidate => candidate.Selectable && !candidate.Controllable && + string.Equals(candidate.EntityKind, "ship", StringComparison.OrdinalIgnoreCase) && !string.Equals(candidate.Faction, marker.Faction, StringComparison.Ordinal)) .OrderBy(candidate => Vector3.Distance(candidate.transform.position, marker.transform.position)) .ToArray(); - Assert.That( - hostileTargets, - Is.Not.Empty, - "No selectable hostile contact was lowered. Visible entities: " + + var targetEntityId = hostileTargets.Length > 0 + ? hostileTargets[0].EntityId + : stationObservedHostileId; + Assert.That(targetEntityId, Is.Not.Empty, + "No hostile contact was available from pilot or shared station sensors. Visible entities: " + string.Join(", ", visibleMarkers.Select(candidate => $"{candidate.EntityId}[kind={candidate.EntityKind},faction={candidate.Faction},selectable={candidate.Selectable}]"))); - var targetMarker = hostileTargets[0]; - var focus = runtime.SubmitTargetIntent(playerId, targetMarker.EntityId); + var focus = runtime.SubmitTargetIntent(playerId, targetEntityId); var focusVersion = runtime.ActiveVersion; var trajectoryRenderer = root.AddComponent(); runtime.ShotAvailable += shot => @@ -464,7 +516,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests combatActionId, combatShot, collectedPickupId, - pickupCollected); + pickupCollected, + docked, + undocked); } finally { @@ -490,7 +544,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests string combatActionId, EveUnityShotReceipt combatShot, string collectedPickupId, - bool pickupCollected) + bool pickupCollected, + bool docked, + bool undocked) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; @@ -509,6 +565,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests shotHit = combatShot?.Hit ?? false, pickupVisible = !string.IsNullOrWhiteSpace(collectedPickupId), pickupCollected = pickupCollected, + docked = docked, + undocked = undocked, pickupId = collectedPickupId ?? "", initialVersion = initialVersion, finalVersion = finalVersion, @@ -535,6 +593,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool shotHit; public bool pickupVisible; public bool pickupCollected; + public bool docked; + public bool undocked; public string pickupId; public long initialVersion; public long finalVersion; @@ -562,9 +622,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests } } - private static EveUnityPlayableWorldEntityMarker FindMarker(GameObject root, string entityId) + private static EveUnityPlayableWorldEntityMarker FindMarker( + GameObject root, + string entityId, + bool includeInactive = false) { - foreach (var marker in root.GetComponentsInChildren()) + foreach (var marker in root.GetComponentsInChildren(includeInactive)) { if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs index 75a9696..e4cb824 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs @@ -21,6 +21,8 @@ namespace GameCult.Eve.UnityScene private readonly Transform _root; private readonly IEveUnityGameObjectAssetProvider _assetProvider; private readonly Dictionary _instances = new Dictionary(StringComparer.Ordinal); + private string _subjectEntityId = ""; + private bool _subjectVisible = true; public EveUnityGameObjectPlayableWorldSceneSink( Transform root, @@ -37,6 +39,9 @@ namespace GameCult.Eve.UnityScene if (world == null) throw new ArgumentNullException(nameof(world)); if (!string.IsNullOrWhiteSpace(world.WorldRootId)) _root.name = world.WorldRootId; + _subjectEntityId = world.PlayerEntityId; + _subjectVisible = world.SubjectVisible; + ApplySubjectVisibility(); } public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) @@ -64,6 +69,7 @@ namespace GameCult.Eve.UnityScene } ApplyEntity(instance, entity, asset); + ApplySubjectVisibility(instance, entity.EntityId); } public void RemoveEntity(string entityId) @@ -140,6 +146,19 @@ namespace GameCult.Eve.UnityScene else UnityEngine.Object.DestroyImmediate(instance); } + + private void ApplySubjectVisibility() + { + foreach (var pair in _instances) + ApplySubjectVisibility(pair.Value, pair.Key); + } + + private void ApplySubjectVisibility(GameObject instance, string entityId) + { + if (instance == null || !string.Equals(entityId, _subjectEntityId, StringComparison.Ordinal)) + return; + instance.SetActive(_subjectVisible); + } } public sealed class EveUnitySemanticEntityPresentation : MonoBehaviour diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs index 996c5bb..942e9f9 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs @@ -53,8 +53,8 @@ namespace GameCult.Eve.UnityScene } var camera = cameraTransform != null ? cameraTransform : transform; - var player = FindPlayerMarker(resolvedHost, activeWorld.PlayerEntityId); - if (player == null) + var targetMarker = FindEntityMarker(resolvedHost, activeWorld.CameraTargetEntityId); + if (targetMarker == null) return false; var cameraComponent = camera.GetComponent(); @@ -64,8 +64,8 @@ namespace GameCult.Eve.UnityScene { cameraComponent.cullingMask = cullingMask; } - var bounds = CalculateVisualBounds(player); - var target = bounds?.center ?? player.transform.position; + var bounds = CalculateVisualBounds(targetMarker); + var target = bounds?.center ?? targetMarker.transform.position; var radius = bounds?.extents.magnitude ?? 0f; var verticalFov = cameraComponent != null ? cameraComponent.fieldOfView : 60f; var framingDistance = radius > 0f @@ -84,9 +84,9 @@ namespace GameCult.Eve.UnityScene return true; } - private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker player) + private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker target) { - var renderers = player.GetComponentsInChildren(includeInactive: false); + var renderers = target.GetComponentsInChildren(includeInactive: false); Bounds? bounds = null; foreach (var visual in renderers) { @@ -121,17 +121,17 @@ namespace GameCult.Eve.UnityScene return host; } - private static EveUnityPlayableWorldEntityMarker? FindPlayerMarker( + private static EveUnityPlayableWorldEntityMarker? FindEntityMarker( EveUnityPlayableWorldClientHost host, - string playerEntityId) + string entityId) { - if (string.IsNullOrWhiteSpace(playerEntityId)) + if (string.IsNullOrWhiteSpace(entityId)) return null; - var markers = host.SceneRoot.GetComponentsInChildren(); + var markers = host.SceneRoot.GetComponentsInChildren(includeInactive: true); foreach (var marker in markers) { - if (marker != null && marker.EntityId == playerEntityId) + if (marker != null && marker.EntityId == entityId) return marker; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs index 03850e1..2ba9bef 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs @@ -39,7 +39,7 @@ namespace GameCult.Eve.UnityScene DateTimeOffset? issuedAt = null) { var resolvedHost = ResolveHost(); - if (resolvedHost == null || resolvedHost.ActiveWorld == null) + if (resolvedHost == null || resolvedHost.ActiveWorld == null || !resolvedHost.ActiveWorld.MovementEnabled) return null; var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput( diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index d5b5905..b0217c3 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -125,7 +125,11 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("entityViewSchema"), worldRoot.GetProp("zoneRenderPointerId"), worldRoot.GetProp("zoneRenderSchema"), - worldRoot.GetProp("inputCapability")); + worldRoot.GetProp("inputCapability"), + worldRoot.GetProp("cameraTargetEntityId"), + ParseBoolean(worldRoot.GetProp("subjectVisible", "true")), + ParseBoolean(worldRoot.GetProp("movementEnabled", "true")), + worldRoot.GetProp("presentationMode", "world")); } private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component) @@ -326,7 +330,11 @@ namespace GameCult.Eve.UnityScene string entityViewSchema = "", string zoneRenderPointerId = "", string zoneRenderSchema = "", - string inputCapabilityPointerId = "") + string inputCapabilityPointerId = "", + string cameraTargetEntityId = "", + bool subjectVisible = true, + bool movementEnabled = true, + string presentationMode = "world") { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -345,6 +353,12 @@ namespace GameCult.Eve.UnityScene ZoneRenderPointerId = zoneRenderPointerId ?? ""; ZoneRenderSchema = zoneRenderSchema ?? ""; InputCapabilityPointerId = inputCapabilityPointerId ?? ""; + CameraTargetEntityId = string.IsNullOrWhiteSpace(cameraTargetEntityId) + ? PlayerEntityId + : cameraTargetEntityId; + SubjectVisible = subjectVisible; + MovementEnabled = movementEnabled; + PresentationMode = presentationMode ?? ""; } public string WorldRootId { get; } @@ -371,6 +385,14 @@ namespace GameCult.Eve.UnityScene public string PlayerEntityId { get; } + public string CameraTargetEntityId { get; } + + public bool SubjectVisible { get; } + + public bool MovementEnabled { get; } + + public string PresentationMode { get; } + public string MovementCommand { get; } public string FocusCommand { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index c0cae75..5c3557e 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -117,6 +117,10 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person")); Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("third-person-orbit")); Assert.That(projection.PlayableWorld.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(projection.PlayableWorld.CameraTargetEntityId, Is.EqualTo("player-vanguard")); + Assert.That(projection.PlayableWorld.SubjectVisible, Is.True); + Assert.That(projection.PlayableWorld.MovementEnabled, Is.True); + Assert.That(projection.PlayableWorld.PresentationMode, Is.EqualTo("world")); Assert.That(projection.PlayableWorld.MovementCommand, Is.EqualTo("aetheria.daemon.move_intent")); Assert.That(projection.PlayableWorld.FocusCommand, Is.EqualTo("aetheria.daemon.focus")); Assert.That(projection.PlayableWorld.TargetCommand, Is.EqualTo("aetheria.daemon.target")); @@ -1419,6 +1423,10 @@ namespace GameCult.Eve.UnityScene.Tests ["inputProfile"] = "arpg-third-person", ["cameraRig"] = "third-person-orbit", ["playerEntityId"] = "player-vanguard", + ["cameraTargetEntityId"] = "player-vanguard", + ["subjectVisible"] = "true", + ["movementEnabled"] = "true", + ["presentationMode"] = "world", ["movementCommand"] = "aetheria.daemon.move_intent", ["focusCommand"] = "aetheria.daemon.focus", ["targetCommand"] = "aetheria.daemon.target", From 59edc30088865cf374c95e34f0d355e58c1a22bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 14:36:59 +0100 Subject: [PATCH 15/16] Prove generic Terminus service loop --- .../PlayMode/GenericWorldCaptureTests.cs | 291 +++++++++++++----- 1 file changed, 218 insertions(+), 73 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index a3d60a7..3480814 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -186,6 +186,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt movementReceipt = null; WitnessReceipt focusReceipt = null; WitnessReceipt actionReceipt = null; + WitnessReceipt tradeReceipt = null; EveUnityShotReceipt combatShot = null; string combatActionId = ""; string collectedPickupId = ""; @@ -258,24 +259,22 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests .Single(candidate => string.Equals(candidate.ActionId, "pilot.dock", StringComparison.Ordinal)); var undockAction = provider.CurrentInputCapability.Actions .Single(candidate => string.Equals(candidate.ActionId, "pilot.undock", StringComparison.Ordinal)); - var dock = runtime.SubmitActionIntent(playerId, dockAction.ActionId); var realtimeAction = provider.CurrentInputCapability?.Actions .FirstOrDefault(action => string.Equals(action.ActionId, "simulation.rate.realtime", StringComparison.Ordinal)); Assert.That(realtimeAction, Is.Not.Null, "Terminus did not advertise its user-controlled simulation clock."); - var resume = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId); - var resumeDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < resumeDeadline && - !commandReceipts.TryGetValue(resume.CommandId, out _)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(resume.CommandId, out var resumeReceipt), Is.True); - Assert.That(resumeReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeReceipt.Message); + var pauseAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "simulation.pause", StringComparison.Ordinal)); + var scoopAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); + var stepAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "simulation.step", StringComparison.Ordinal)); Assert.That( marker.GetComponentsInChildren(includeInactive: true).Length, Is.GreaterThan(1), "The generic client substituted its primitive fallback instead of the provider-authored AssetBundle prefab."); + runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); + var dock = runtime.SubmitActionIntent(playerId, dockAction.ActionId); + var resumeDock = runtime.SubmitActionIntent(playerId, stepAction.ActionId); var dockDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < dockDeadline && (!commandReceipts.TryGetValue(dock.CommandId, out var observedDock) || @@ -285,6 +284,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } + Assert.That(commandReceipts.TryGetValue(resumeDock.CommandId, out var resumeDockReceipt), Is.True); + Assert.That(resumeDockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeDockReceipt.Message); Assert.That(commandReceipts.TryGetValue(dock.CommandId, out var dockReceipt), Is.True); Assert.That(dockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), dockReceipt.Message); Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("docked")); @@ -300,7 +301,44 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(FindMarker(root, playerId, includeInactive: true).gameObject.activeSelf, Is.False); docked = true; + var servicePause = runtime.SubmitActionIntent(playerId, pauseAction.ActionId); + var servicePauseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < servicePauseDeadline && + (!commandReceipts.TryGetValue(servicePause.CommandId, out var observedServicePause) || + runtime.ActiveVersion < observedServicePause.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(servicePause.CommandId, out var servicePauseReceipt), Is.True); + Assert.That(servicePauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), servicePauseReceipt.Message); + + var tradeAction = provider.CurrentInputCapability.Actions + .FirstOrDefault(candidate => + string.Equals(candidate.Category, "trade", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); + Assert.That(tradeAction, Is.Not.Null, + "The docked pilot input capability did not advertise an available daemon-authored station stock action. Actions: " + + string.Join("; ", provider.CurrentInputCapability.Actions.Select(candidate => + $"{candidate.ActionId}[category={candidate.Category},availability={candidate.Availability},label={candidate.Label}]"))); + var trade = runtime.SubmitActionIntent(playerId, tradeAction.ActionId); + var tradeDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < tradeDeadline && + (!commandReceipts.TryGetValue(trade.CommandId, out var observedTrade) || + runtime.ActiveVersion < observedTrade.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(trade.CommandId, out var tradeCommandReceipt), Is.True); + Assert.That( + tradeCommandReceipt.State, + Is.EqualTo("accepted").Or.EqualTo("reconciled"), + tradeCommandReceipt.Message); + tradeReceipt = WitnessReceipt.From("trade", tradeCommandReceipt); + var undock = runtime.SubmitActionIntent(playerId, undockAction.ActionId); + var resumeDeparture = runtime.SubmitActionIntent(playerId, stepAction.ActionId); var undockDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < undockDeadline && (!commandReceipts.TryGetValue(undock.CommandId, out var observedUndock) || @@ -310,6 +348,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } + Assert.That(commandReceipts.TryGetValue(resumeDeparture.CommandId, out var resumeDepartureReceipt), Is.True); + Assert.That(resumeDepartureReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeDepartureReceipt.Message); Assert.That(commandReceipts.TryGetValue(undock.CommandId, out var undockReceipt), Is.True); Assert.That(undockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), undockReceipt.Message); Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("world")); @@ -318,60 +358,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(runtime.ActiveWorld.MovementEnabled, Is.True); Assert.That(FindMarker(root, playerId).gameObject.activeSelf, Is.True); undocked = true; + var undockPosition = marker.transform.position; - var initialPosition = marker.transform.position; - initialVersion = runtime.ActiveVersion; - var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); - - var deadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < deadline && - (!commandReceipts.TryGetValue(request.CommandId, out var observedMovement) || - runtime.ActiveVersion < observedMovement.SourceVersion || - Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - - Assert.That(commandReceipts.TryGetValue(request.CommandId, out var movementCommandReceipt), Is.True); - Assert.That( - movementCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - movementCommandReceipt.Message); - Assert.That(movementCommandReceipt.SourceVersion, Is.GreaterThan(initialVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(movementCommandReceipt.SourceVersion)); - movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); - Assert.That(movementDistance, Is.GreaterThan(0.01f)); - var pickupDeadline = Time.realtimeSinceStartup + 8f; - while (Time.realtimeSinceStartup < pickupDeadline && - root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal))) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - pickupCollected = !root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - Assert.That(pickupCollected, Is.True, "Ymir/daemon simulation did not collect the seeded pickup when the player crossed it."); - movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt); - - var stop = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); - var stopDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < stopDeadline && - (!commandReceipts.TryGetValue(stop.CommandId, out var observedStop) || - runtime.ActiveVersion < observedStop.SourceVersion)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(stop.CommandId, out var stopCommandReceipt), Is.True); - Assert.That( - stopCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - stopCommandReceipt.Message); - - var pauseAction = provider.CurrentInputCapability.Actions - .Single(candidate => string.Equals(candidate.ActionId, "simulation.pause", StringComparison.Ordinal)); var pause = runtime.SubmitActionIntent(playerId, pauseAction.ActionId); var pauseDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < pauseDeadline && @@ -384,7 +372,37 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(commandReceipts.TryGetValue(pause.CommandId, out var pauseReceipt), Is.True); Assert.That(pauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), pauseReceipt.Message); - var movementVersion = runtime.ActiveVersion; + var scoop = runtime.SubmitActionIntent(playerId, scoopAction.ActionId); + var initialScoopDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < initialScoopDeadline && + !commandReceipts.TryGetValue(scoop.CommandId, out _)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(scoop.CommandId, out var initialScoopReceipt), Is.True); + Assert.That(initialScoopReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), initialScoopReceipt.Message); + for (var simulationStep = 0; simulationStep < 300 && + root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + simulationStep++) + { + var step = runtime.SubmitActionIntent(playerId, stepAction.ActionId); + var stepDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < stepDeadline && + !commandReceipts.TryGetValue(step.CommandId, out _)) + { + yield return new WaitForSecondsRealtime(0.05f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(step.CommandId, out var stepReceipt), Is.True); + Assert.That(stepReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), stepReceipt.Message); + } + pickupCollected = !root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + Assert.That(pickupCollected, Is.True, "The pilot bot did not collect the daemon-authored salvage under explicit simulation steps."); + + var combatStartVersion = runtime.ActiveVersion; var visibleMarkers = root.GetComponentsInChildren(); var hostileTargets = visibleMarkers .Where(candidate => candidate.Selectable && !candidate.Controllable && @@ -392,15 +410,20 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests !string.Equals(candidate.Faction, marker.Faction, StringComparison.Ordinal)) .OrderBy(candidate => Vector3.Distance(candidate.transform.position, marker.transform.position)) .ToArray(); - var targetEntityId = hostileTargets.Length > 0 - ? hostileTargets[0].EntityId - : stationObservedHostileId; - Assert.That(targetEntityId, Is.Not.Empty, - "No hostile contact was available from pilot or shared station sensors. Visible entities: " + + Assert.That(hostileTargets, Is.Not.Empty, + "No hostile contact was available from the ship's own sensors immediately after undocking. " + + $"The station-only contact was {stationObservedHostileId}. Current entities: " + string.Join(", ", visibleMarkers.Select(candidate => $"{candidate.EntityId}[kind={candidate.EntityKind},faction={candidate.Faction},selectable={candidate.Selectable}]"))); + var targetEntityId = hostileTargets[0].EntityId; var focus = runtime.SubmitTargetIntent(playerId, targetEntityId); var focusVersion = runtime.ActiveVersion; + var aimVector = hostileTargets[0].transform.position - marker.transform.position; + var aim = runtime.SubmitMoveVectorIntent( + playerId, + aimVector.x, + aimVector.z, + 0.1f); var trajectoryRenderer = root.AddComponent(); runtime.ShotAvailable += shot => { @@ -434,8 +457,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests while (Time.realtimeSinceStartup < actionDeadline && (!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) || runtime.ActiveVersion < observedFocus.SourceVersion || + !commandReceipts.TryGetValue(aim.CommandId, out var observedAim) || + runtime.ActiveVersion < observedAim.SourceVersion || !commandReceipts.TryGetValue(action.CommandId, out var observedAction) || - runtime.ActiveVersion < observedAction.SourceVersion || combatShot == null)) + runtime.ActiveVersion < observedAction.SourceVersion || + Vector3.Distance(FindMarker(root, playerId).transform.position, undockPosition) < 0.01f || + combatShot == null)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); @@ -445,8 +472,16 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests focusCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), focusCommandReceipt.Message); - Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(movementVersion)); + Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(combatStartVersion)); focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt); + Assert.That(commandReceipts.TryGetValue(aim.CommandId, out var aimCommandReceipt), Is.True); + Assert.That( + aimCommandReceipt.State, + Is.EqualTo("accepted").Or.EqualTo("reconciled"), + aimCommandReceipt.Message); + movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, undockPosition); + movementReceipt = WitnessReceipt.From("movement", aimCommandReceipt); + Assert.That(movementDistance, Is.GreaterThan(0.01f)); Assert.That(commandReceipts.TryGetValue(action.CommandId, out var actionCommandReceipt), Is.True); Assert.That( actionCommandReceipt.State, @@ -465,6 +500,112 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(trajectoryRenderer.ActiveTrajectoryCount, Is.GreaterThan(0)); actionReceipt = WitnessReceipt.From("action", actionCommandReceipt); + if (!pickupCollected) + { + var retryScoopAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); + var retryScoop = runtime.SubmitActionIntent(playerId, retryScoopAction.ActionId); + var scoopDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < scoopDeadline && + (!commandReceipts.TryGetValue(retryScoop.CommandId, out var observedScoop) || + runtime.ActiveVersion < observedScoop.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(retryScoop.CommandId, out var scoopCommandReceipt), Is.True); + Assert.That( + scoopCommandReceipt.State, + Is.EqualTo("accepted").Or.EqualTo("reconciled"), + scoopCommandReceipt.Message); + + pickupCollected = !root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + if (pickupCollected) + { + movementDistance = Vector3.Distance(marker.transform.position, undockPosition); + movementReceipt = WitnessReceipt.From("movement", aimCommandReceipt); + } + else + { + var initialPosition = marker.transform.position; + initialVersion = runtime.ActiveVersion; + var currentPickup = root.GetComponentsInChildren() + .Single(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + var pickupVector = currentPickup.transform.position - marker.transform.position; + var request = runtime.SubmitMoveVectorIntent(playerId, pickupVector.x, pickupVector.z, 1f); + + var deadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < deadline && + (!commandReceipts.TryGetValue(request.CommandId, out var observedMovement) || + runtime.ActiveVersion < observedMovement.SourceVersion || + Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + + Assert.That(commandReceipts.TryGetValue(request.CommandId, out var movementCommandReceipt), Is.True); + Assert.That( + movementCommandReceipt.State, + Is.EqualTo("accepted").Or.EqualTo("reconciled"), + movementCommandReceipt.Message); + Assert.That(movementCommandReceipt.SourceVersion, Is.GreaterThan(initialVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(movementCommandReceipt.SourceVersion)); + movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); + var pickupDeadline = Time.realtimeSinceStartup + 20f; + var nextSteeringUpdate = 0f; + while (Time.realtimeSinceStartup < pickupDeadline && + root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal))) + { + if (Time.realtimeSinceStartup >= nextSteeringUpdate) + { + var currentPlayer = FindMarker(root, playerId); + var observedPickup = root.GetComponentsInChildren() + .FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + if (observedPickup != null) + { + var correction = observedPickup.transform.position - currentPlayer.transform.position; + runtime.SubmitMoveVectorIntent(playerId, correction.x, correction.z, 1f); + } + nextSteeringUpdate = Time.realtimeSinceStartup + 0.25f; + } + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + pickupCollected = !root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt); + } + Assert.That(movementDistance, Is.GreaterThan(0.01f)); + var remainingPickup = root.GetComponentsInChildren() + .FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + Assert.That( + pickupCollected, + Is.True, + remainingPickup == null + ? "Ymir/daemon simulation retained a collected pickup outside the active Eve entity view." + : $"Ymir/daemon simulation produced no pickup contact. " + + $"player={marker.transform.position}, pickup={remainingPickup.transform.position}, " + + $"distance={Vector3.Distance(marker.transform.position, remainingPickup.transform.position):0.###}."); + } + + var stop = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); + var stopDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < stopDeadline && + (!commandReceipts.TryGetValue(stop.CommandId, out var observedStop) || + runtime.ActiveVersion < observedStop.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(stop.CommandId, out var stopCommandReceipt), Is.True); + Assert.That( + stopCommandReceipt.State, + Is.EqualTo("accepted").Or.EqualTo("reconciled"), + stopCommandReceipt.Message); + var light = lightObject.AddComponent(); light.type = LightType.Directional; light.intensity = 1.4f; @@ -513,6 +654,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movementReceipt, focusReceipt, actionReceipt, + tradeReceipt, combatActionId, combatShot, collectedPickupId, @@ -541,6 +683,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt movement, WitnessReceipt targeting, WitnessReceipt action, + WitnessReceipt trade, string combatActionId, EveUnityShotReceipt combatShot, string collectedPickupId, @@ -557,6 +700,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movement = movement != null, targeting = targeting != null, action = action != null, + trade = trade != null, combatShot = combatShot != null, combatActionId = combatActionId ?? "", shotId = combatShot?.ShotId ?? "", @@ -571,7 +715,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, - receipts = new[] { movement, targeting, action } + receipts = new[] { movement, targeting, action, trade } }; Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); File.WriteAllText(path, JsonUtility.ToJson(document, true)); @@ -585,6 +729,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool movement; public bool targeting; public bool action; + public bool trade; public bool combatShot; public string combatActionId; public string shotId; From d32ccf2347a41af10c23351c725c2baa9fc7baa2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 15:23:46 +0100 Subject: [PATCH 16/16] Prove authoritative cargo collection in Unity --- .../PlayMode/GenericWorldCaptureTests.cs | 90 +++++++++++++++---- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 3480814..2a00a34 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -196,6 +196,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests string stationObservedHostileId = ""; long initialVersion = 0; float movementDistance = 0f; + int cargoQuantityBeforePickup = 0; + int cargoQuantityAfterPickup = 0; var commandReceipts = new Dictionary(StringComparer.Ordinal); try @@ -372,6 +374,11 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(commandReceipts.TryGetValue(pause.CommandId, out var pauseReceipt), Is.True); Assert.That(pauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), pauseReceipt.Message); + cargoQuantityBeforePickup = ReadInt32EntitySemantic( + provider.CurrentEntityView, + playerId, + "inventory.cargo.quantity"); + var scoop = runtime.SubmitActionIntent(playerId, scoopAction.ActionId); var initialScoopDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < initialScoopDeadline && @@ -382,25 +389,47 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests } Assert.That(commandReceipts.TryGetValue(scoop.CommandId, out var initialScoopReceipt), Is.True); Assert.That(initialScoopReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), initialScoopReceipt.Message); - for (var simulationStep = 0; simulationStep < 300 && - root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - simulationStep++) + var resumeScoop = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId); + var resumeScoopDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < resumeScoopDeadline && + (!commandReceipts.TryGetValue(resumeScoop.CommandId, out var observedResumeScoop) || + runtime.ActiveVersion < observedResumeScoop.SourceVersion)) { - var step = runtime.SubmitActionIntent(playerId, stepAction.ActionId); - var stepDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < stepDeadline && - !commandReceipts.TryGetValue(step.CommandId, out _)) - { - yield return new WaitForSecondsRealtime(0.05f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(step.CommandId, out var stepReceipt), Is.True); - Assert.That(stepReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), stepReceipt.Message); + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(resumeScoop.CommandId, out var resumeScoopReceipt), Is.True); + Assert.That(resumeScoopReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeScoopReceipt.Message); + var collectionDeadline = Time.realtimeSinceStartup + 25f; + while (Time.realtimeSinceStartup < collectionDeadline && + root.GetComponentsInChildren() + .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); } pickupCollected = !root.GetComponentsInChildren() .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - Assert.That(pickupCollected, Is.True, "The pilot bot did not collect the daemon-authored salvage under explicit simulation steps."); + var uncollectedPickup = root.GetComponentsInChildren() + .FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); + var currentCargoQuantity = ReadInt32EntitySemantic( + provider.CurrentEntityView, + playerId, + "inventory.cargo.quantity"); + Assert.That(pickupCollected, Is.True, + uncollectedPickup == null + ? "The pilot bot did not collect the daemon-authored salvage." + : $"The pilot bot did not collect the daemon-authored salvage. " + + $"player={FindMarker(root, playerId).transform.position}, " + + $"pickup={uncollectedPickup.transform.position}, " + + $"distance={Vector3.Distance(FindMarker(root, playerId).transform.position, uncollectedPickup.transform.position):0.###}, " + + $"cargo={currentCargoQuantity}."); + cargoQuantityAfterPickup = ReadInt32EntitySemantic( + provider.CurrentEntityView, + playerId, + "inventory.cargo.quantity"); + Assert.That(cargoQuantityAfterPickup, Is.EqualTo(cargoQuantityBeforePickup + 1), + "The pickup left the world without an authoritative cargo-unit delta in the portable Eve SoA view."); var combatStartVersion = runtime.ActiveVersion; var visibleMarkers = root.GetComponentsInChildren(); @@ -659,6 +688,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests combatShot, collectedPickupId, pickupCollected, + cargoQuantityBeforePickup, + cargoQuantityAfterPickup, docked, undocked); } @@ -688,6 +719,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests EveUnityShotReceipt combatShot, string collectedPickupId, bool pickupCollected, + int cargoQuantityBeforePickup, + int cargoQuantityAfterPickup, bool docked, bool undocked) { @@ -709,6 +742,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests shotHit = combatShot?.Hit ?? false, pickupVisible = !string.IsNullOrWhiteSpace(collectedPickupId), pickupCollected = pickupCollected, + cargoQuantityBeforePickup = cargoQuantityBeforePickup, + cargoQuantityAfterPickup = cargoQuantityAfterPickup, docked = docked, undocked = undocked, pickupId = collectedPickupId ?? "", @@ -738,6 +773,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool shotHit; public bool pickupVisible; public bool pickupCollected; + public int cargoQuantityBeforePickup; + public int cargoQuantityAfterPickup; public bool docked; public bool undocked; public string pickupId; @@ -747,6 +784,29 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public WitnessReceipt[] receipts; } + private static int ReadInt32EntitySemantic( + EveEntitySoaViewDocument document, + string entityId, + string semantic) + { + Assert.That(document, Is.Not.Null, "The provider did not publish an Eve entity SoA view."); + var entityIndex = document.Identities + .Single(identity => string.Equals(identity.EntityId, entityId, StringComparison.Ordinal)) + .EntityIndex; + using var view = EveUnityEntitySoaView.Open(document); + for (var row = 0; row < view.EntityCount; row++) + { + if (!view.TryReadInt32("entity.index", row, out var rowEntityIndex) || rowEntityIndex != entityIndex) + continue; + Assert.That(view.TryReadInt32(semantic, row, out var value), Is.True, + $"The Eve entity SoA view did not expose int32 semantic '{semantic}'."); + return value; + } + + Assert.Fail($"The Eve entity SoA view did not contain entity '{entityId}' ({entityIndex})."); + return 0; + } + [Serializable] private sealed class WitnessReceipt {