From befdd6a4a1f0376b40a3103aae811670336c0fee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 09:07:55 +0100 Subject: [PATCH 01/45] Give UI Toolkit commands idempotency keys --- .../Runtime/EveUiToolkitSurfaceLowerer.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index 00809f2..52b2c47 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -448,10 +448,14 @@ namespace GameCult.Eve.UnityUIToolkit foreach (var template in document.Commands) { if (string.Equals(template.Command, command, StringComparison.Ordinal)) - return GameCult.Mesh.CultMesh.OperationInvocation(template.Operation); + return GameCult.Mesh.CultMesh.OperationInvocation( + template.Operation, + idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}"); } - return GameCult.Mesh.CultMesh.OperationInvocation(command); + return GameCult.Mesh.CultMesh.OperationInvocation( + command, + idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}"); } private static Label TitleLabel(string text) From 7c1dd5a17d69cf03faf286c34a6880355a8ace5a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 16:26:00 +0100 Subject: [PATCH 02/45] Lower typed Eve select controls in Unity --- .../Runtime/EveUiToolkitSurfaceLowerer.cs | 47 +++++++++++++++++++ .../Editor/EveUiToolkitSurfaceLowererTests.cs | 29 ++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index 52b2c47..f6e4fcd 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -49,6 +49,11 @@ namespace GameCult.Eve.UnityUIToolkit if (IsHidden(component) || IsExternalProjectionRoot(component.Kind)) return element; + // Select options are semantic children consumed by the control itself. + // They are not separately lowered visual elements. + if (NormalizeKind(component.Kind) == "control.select") + return element; + foreach (var child in component.Children) { var loweredChild = LowerComponent(child, document, commandSink); @@ -114,6 +119,8 @@ namespace GameCult.Eve.UnityUIToolkit return InventoryItem(component, document, commandSink); case EveInventoryInteraction.DragSessionKind: return InventoryDragSession(component); + case "control.select": + return Select(component, document, commandSink); case "partition": { var element = new VisualElement(); @@ -458,6 +465,46 @@ namespace GameCult.Eve.UnityUIToolkit idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}"); } + private static VisualElement Select( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var options = component.Children + .Where(child => NormalizeKind(child.Kind) == "control.option") + .Select(child => new + { + Label = child.GetProp("label", child.GetProp("value")), + Value = child.GetProp("value") + }) + .Where(option => !string.IsNullOrWhiteSpace(option.Value)) + .ToArray(); + var labels = options.Select(option => option.Label).ToList(); + var selectedValue = component.GetProp("value"); + var selectedIndex = Array.FindIndex(options, option => + string.Equals(option.Value, selectedValue, StringComparison.Ordinal)); + var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex)); + field.SetEnabled(!ParseBool(component.GetProp("disabled"))); + field.RegisterValueChangedCallback(change => + { + var option = options.FirstOrDefault(candidate => + string.Equals(candidate.Label, change.newValue, StringComparison.Ordinal)); + if (option == null) + return; + var payload = new Dictionary(component.Props, StringComparer.Ordinal) + { + ["value"] = option.Value + }; + EmitCommand( + document, + component, + component.GetProp("command", component.GetProp("operationId")), + GameCult.Mesh.CultMesh.OperationPayload(payload), + commandSink); + }); + return field; + } + private static Label TitleLabel(string text) { var label = new Label(text); diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs index 38b2fcb..c870112 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -11,6 +11,35 @@ namespace GameCult.Eve.UnityUIToolkit.Tests { public sealed class EveUiToolkitSurfaceLowererTests { + [Test] + public void SelectLowersProviderOptionsAndEmitsSelectedTypedValue() + { + EveSurfaceCommandRequest? emitted = null; + var select = Component( + "verse", + "control.select", + new Dictionary + { + ["label"] = "VERSE", + ["value"] = "local", + ["command"] = "eve.client.verse.select" + }, + new[] + { + Component("local", "control.option", new Dictionary { ["label"] = "Local", ["value"] = "local" }), + Component("hosted", "control.option", new Dictionary { ["label"] = "Hosted", ["value"] = "hosted" }) + }); + var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request); + var field = root.Q(); + + Assert.That(field, Is.Not.Null); + Assert.That(field.choices, Is.EqualTo(new[] { "Local", "Hosted" })); + field.value = "Hosted"; + Assert.That(emitted, Is.Not.Null); + Assert.That(emitted!.Command, Is.EqualTo("eve.client.verse.select")); + Assert.That(emitted.PayloadFields["value"], Is.EqualTo("hosted")); + } + [Test] public void DefaultOptionsExposeSaiNornAndTeXProjectionAdapters() { From 9833d40172126c2b7c7467ac010137124166473a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 21:02:01 +0200 Subject: [PATCH 03/45] Route Eve Unity through stable CultMesh identity --- README.md | 5 +- .../PlayMode/GenericWorldCaptureTests.cs | 6 +- .../PlayMode/GenericWorldCaptureTests.cs | 6 +- TestProject/Packages/packages-lock.json | 7 +- docs/authority-map.md | 23 +- .../org.gamecult.eve.unity-scene/README.md | 6 + .../EveUnityCultMeshLiveProviderTransport.cs | 410 +++++------------- .../EveUnityCultMeshPlayableWorldProvider.cs | 19 +- .../EveUnityCultMeshProviderDiscovery.cs | 63 +-- .../EveUnityCultMeshAssetLookupTests.cs | 34 ++ .../EveUnitySceneSurfaceLowererTests.cs | 33 +- .../org.gamecult.eve.unity-scene/package.json | 2 +- scripts/run-aetheria-daemon-world-witness.ps1 | 10 +- scripts/run-interactive-aetheria.ps1 | 8 +- 14 files changed, 256 insertions(+), 376 deletions(-) diff --git a/README.md b/README.md index 3e36f56..2f76872 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ assembly is part of the client. `EveUnityCultMeshPlayableWorldProvider` is the shipped composition root. Its provider, surface, and Verse filters default to empty, so a client can select by -the generic `interactive-world` contract alone. +the generic `interactive-world` contract alone. After selection, Unity retains +the rendezvous address, Verse ID, and stable provider identity only. `CultMeshClient` owns +physical route discovery, reconnection, typed live-resource leases, command +submission, and content/body sessions. ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\run-package-tests.ps1 diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 8cb8b1d..0bfa008 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? ""; var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? ""; - var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ?? - Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc"); + var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ?? + Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}"); var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ?? Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png"); var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ?? @@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests provider = root.AddComponent(); provider.Configure( rendezvousEndpoint, - replicaPath, + cacheDirectory, providerId, surfaceId, requiredSurfaceKind: "interactive-world", diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 8cb8b1d..0bfa008 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? ""; var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? ""; - var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ?? - Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc"); + var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ?? + Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}"); var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ?? Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png"); var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ?? @@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests provider = root.AddComponent(); provider.Configure( rendezvousEndpoint, - replicaPath, + cacheDirectory, providerId, surfaceId, requiredSurfaceKind: "interactive-world", diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 8034b4d..7b602ba 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -145,9 +145,10 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.14", + "org.gamecult.cultlib": "1.0.43", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.2", + "org.gamecult.eve.surface": "0.2.4", + "org.gamecult.eve.unity-uitoolkit": "0.1.2", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" @@ -158,7 +159,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.eve.surface": "0.1.0" + "org.gamecult.eve.surface": "0.2.4" } }, "com.unity.modules.assetbundle": { diff --git a/docs/authority-map.md b/docs/authority-map.md index ac00b6d..4b25203 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -9,6 +9,9 @@ importing provider product code into the generic runtime. EveUnity owns Unity scene projection, `GameObject` lifecycle, camera and input drivers, asset-provider hooks, package release, runtime tests, and captures. +`CultMeshClient` owns physical route selection, reconnection, shared typed +resource sessions, and their lease lifetime. Unity receives stable Verse and +provider identities and cannot preserve a physical route as gameplay authority. ## Inputs @@ -40,6 +43,10 @@ presentation counters, and receipt display state are projections. They are not world truth. Temporal targets reset when the volume program, node, or render size changes and can never become provider state. +The optional Unity cache directory contains verified content-transfer state and +mapped body bytes only. It is disposable derived data, not a CultCache replica +of provider gameplay documents. + Camera-relative field viewports have one generic resolver. The advertised viewport frame owns its extent, spatial-cell scale, raster texels per cell, and snap policy; fog volumes and stateless particle programs consume the identical @@ -65,6 +72,11 @@ temporal pass must also advertise its current-sample, history, previous-view-projection, and reset-history ports; partial temporal programs fail closed. +Unity code may not open provider snapshot sessions, schema clients, write +forwarders, subscriptions, or replica shards against a discovered physical +route. Reads, watches, content/body transfer, realtime frames, and command +submissions share the same stable-identity `CultMeshClient` owner. + Viewport-sized shader inputs are likewise presentation state. A provider may relate a logical vector port to a logical native texture port through `viewportTextureScaleBindings`; EveUnity derives the vector from the active @@ -86,11 +98,12 @@ modes into one camera opinion or infer a product camera from entity kind. ## Shared Paths -Initial connect discovers a Verse endpoint and selects a provider surface by -semantic kind. Refresh, reconnect, and terminal-receipt reconciliation consume -that provider's surface documents. Movement, focus, target, and action input all -become `gamecult.eve.command_invocation.v1` through the provider-advertised -command boundary. +Initial connect asks one rendezvous for Verses, selects a provider surface by +semantic kind, then leases it through the selected stable provider identity. +Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain +inside the same identity-owned session. Movement, focus, target, and action +input all become `gamecult.eve.command_invocation.v1` through the +provider-advertised command boundary. ## Cut Line diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 005e991..6edac13 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -17,6 +17,12 @@ networked client. Give it one Verse rendezvous endpoint and it discovers an advertised `interactive-world`, constructs the generic CultMesh transport, and provides every bootstrap port. Provider, surface, and Verse IDs are optional selection filters; they are not product knowledge required by the client. +The selected provider ID remains the stable CultMesh endpoint identity while +the Verse ID names the selected rule/progression world. Physical routes are +resolved and re-resolved inside `CultMeshClient`; they are never +published as provider selection state or cached as Unity authority. The optional +local path is only an ephemeral content/body cache, not a replicated gameplay +database. Await `PrepareAsync()` before mounting the bootstrap or reading provider ports. Discovery and session setup never run synchronously from Unity lifecycle or input getters, so an unavailable provider cannot stall the editor main thread. 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 edf1e38..4ee1b88 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -7,13 +7,11 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; -using GameCult.Caching.MessagePack; using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; using GameCult.Eve.UnityScene.Fields; using GameCult.Mesh; using GameCult.Mesh.Quic.Native; -using GameCult.Networking; using UnityEngine; using Stopwatch = System.Diagnostics.Stopwatch; @@ -26,25 +24,9 @@ namespace GameCult.Eve.UnityScene IEveUnityGameObjectAssetProvider, IDisposable { - private const string RemoteShardId = "provider"; - - private static readonly Type[] WireDocumentTypes = - { - typeof(EveProviderAdvertisementDocument), - typeof(EveSurfaceDocument), - typeof(EveInputCapabilityDocument), - typeof(EveSurfaceCommandRequest), - typeof(EveCommandReceiptDocument), - typeof(EveAssetCatalogDocument), - typeof(EveEntitySoaViewDocument), - typeof(EveFieldsSplatsDocument), - typeof(CultMeshBodyPublicationDocument), - typeof(CultMeshCdnArtifactManifest), - typeof(CultMeshContentTransferStateDocument) - }; - - private readonly string _replicaPath; - private readonly string _endpoint; + private readonly string _cachePath; + private readonly string _rendezvousEndpoint; + private readonly string _endpointId; private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; @@ -73,15 +55,11 @@ namespace GameCult.Eve.UnityScene new HashSet(StringComparer.Ordinal); private readonly Dictionary _embeddedSurfaces = new Dictionary(StringComparer.Ordinal); - private CultMeshNode? _node; - private CultMeshSnapshotSession? _snapshot; - private CultNetDocumentRegistry? _networkRegistry; - private CultNetShardDescriptor? _replicaShard; - private CultNetDatabaseSubscriptionClient? _subscriptions; - private CultNetDatabaseSubscriptionClient? _entitySubscriptions; - private CultMeshLiveBody? _entityBody; - private CultNetLiveValue? _inputCapability; + private readonly List _documentLeases = new List(); + private readonly List _documentWatches = new List(); private CultMeshClient? _meshClient; + private bool _liveDocumentsReady; + private CultCache? _contentState; private CultMeshContentTransferService? _contentTransfer; private CultMeshVerifiedBodyMappingBroker? _assetBodyMappings; private EveProviderAdvertisementDocument? _advertisement; @@ -103,19 +81,23 @@ namespace GameCult.Eve.UnityScene private bool _disposed; public EveUnityCultMeshLiveProviderTransport( - string replicaPath, - string endpoint, + string cachePath, + string rendezvousEndpoint, + string endpointId, string providerId, string surfaceId, string runtimeId = "eve-unity", CultMeshBodyPublicationResolver? bodyResolver = null) { - _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(); + _cachePath = string.IsNullOrWhiteSpace(cachePath) + ? throw new ArgumentException("Cache path must be non-empty.", nameof(cachePath)) + : Path.GetFullPath(cachePath); + _rendezvousEndpoint = string.IsNullOrWhiteSpace(rendezvousEndpoint) + ? throw new ArgumentException("CultMesh rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)) + : rendezvousEndpoint.Trim(); + _endpointId = string.IsNullOrWhiteSpace(endpointId) + ? throw new ArgumentException("CultMesh stable endpoint identity must be non-empty.", nameof(endpointId)) + : endpointId.Trim(); _providerId = string.IsNullOrWhiteSpace(providerId) ? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)) : providerId.Trim(); @@ -129,7 +111,7 @@ namespace GameCult.Eve.UnityScene CurrentInputCapability = new EveInputCapabilityDocument(); } - public string TransportKind => "eve-cultmesh-remote-replica"; + public string TransportKind => "eve-cultmesh-identity-session"; public string SurfacePointer => CurrentSurfaceDocument.SourcePointer; @@ -193,7 +175,6 @@ namespace GameCult.Eve.UnityScene try { - _snapshot ??= CreateSnapshotSession(); var elapsed = Stopwatch.StartNew(); ResolveAdvertisement(forceRefresh: true); TraceStartup("advertisement", elapsed); @@ -201,15 +182,14 @@ namespace GameCult.Eve.UnityScene TraceStartup("surface", elapsed); RefreshAssetCatalog(); TraceStartup("asset-catalog-and-bundles", elapsed); - EnsureLiveSubscriptions(); + EnsureLiveDocuments(); TraceStartup("subscriptions", elapsed); _bootstrapped = true; } catch (Exception error) when (error is IOException || error is SocketException || error is TimeoutException) { - ResetSnapshotSession(); throw new InvalidOperationException( - $"CultMesh provider '{_providerId}' bootstrap transport failed; the snapshot lane was reset for retry.", + $"CultMesh provider '{_providerId}' bootstrap through stable endpoint '{_endpointId}' failed.", error); } } @@ -237,8 +217,6 @@ namespace GameCult.Eve.UnityScene public void PumpLiveEvents() { - ThrowIfSubscriptionFailed(_entitySubscriptions); - ThrowIfSubscriptionFailed(_subscriptions); while (_liveDocuments.TryDequeue(out var document)) { if (document is PendingEntityGeneration entityGeneration) @@ -266,15 +244,6 @@ namespace GameCult.Eve.UnityScene PumpMappedEntityFrame(); } - private static void ThrowIfSubscriptionFailed(CultNetDatabaseSubscriptionClient? subscription) - { - var failure = subscription?.BackgroundFailure; - if (failure?.IsCompletedSuccessfully == true) - throw new InvalidOperationException( - "The retained CultMesh state subscription failed.", - failure.Result); - } - private void QueueEntityView(EveEntitySoaViewDocument document) { TraceHotState($"layout epoch={document.ProducerEpoch} sequence={document.Sequence} identities={document.Identities?.Length ?? 0}"); @@ -391,17 +360,12 @@ namespace GameCult.Eve.UnityScene try { var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); - var message = _networkRegistry!.CreateRawDocumentPutMessage( - $"eve-unity-{commandId}", - new CultRecordHandle(new CultRecordKey(recordKey)), - request, - new CultNetDocumentMessageOptions - { - SourceRuntimeId = _runtimeId, - SourceRole = "eve-unity" - }); - new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions()) - .ForwardPutAsync(_replicaShard!, message) + _meshClient!.SubmitDocumentAsync( + _endpointId, + recordKey, + request, + _runtimeId, + "eve-unity") .GetAwaiter() .GetResult(); } @@ -441,24 +405,13 @@ namespace GameCult.Eve.UnityScene _realtimePump = null; _realtimeLifetime?.Dispose(); _realtimeLifetime = null; - _entityBody?.Dispose(); - _entityBody = null; - _inputCapability?.Dispose(); - _inputCapability = null; - _subscriptions?.Dispose(); - _entitySubscriptions?.Dispose(); - _node?.Dispose(); - _snapshot?.Dispose(); + DisposeLiveDocuments(); + _contentState?.Dispose(); + _contentState = null; _meshClient?.Dispose(); - _node = null; - _snapshot = null; - _subscriptions = null; - _entitySubscriptions = null; _meshClient = null; _contentTransfer = null; _assetBodyMappings = null; - _networkRegistry = null; - _replicaShard = null; _baseSurface = null; _embeddedSurfaces.Clear(); _pendingCommandIds.Clear(); @@ -496,44 +449,11 @@ namespace GameCult.Eve.UnityScene private void EnsureOpen() { - if (_node != null) + if (_meshClient != 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(); - _snapshot = CreateSnapshotSession(); _meshClient = new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = new[] { _endpoint }, + RendezvousEndpoints = new[] { _rendezvousEndpoint }, RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] { new CultMeshNativeQuicRealtimeTransportConnector() @@ -541,36 +461,15 @@ namespace GameCult.Eve.UnityScene }); } - private CultMeshSnapshotSession CreateSnapshotSession() => - CultMesh.SnapshotSession( - _endpoint, - new CultMeshSnapshotRequestOptions - { - ShardId = RemoteShardId, - ShardEpoch = 1, - ConnectTimeout = TimeSpan.FromSeconds(5), - ResponseTimeout = TimeSpan.FromSeconds(30), - MessageIdPrefix = "eve-unity", - RudpRuntimeId = _runtimeId, - RudpMaxFragmentBytes = 1024 - }, - _networkRegistry); - - private void ResetSnapshotSession() - { - _snapshot?.Dispose(); - _snapshot = null; - } - private void ResolveAdvertisement(bool forceRefresh = false) { if (!forceRefresh && _advertisement != null && _advertisedSurface != null) return; - var documents = _snapshot! - .FetchDocumentsAsync() + using var advertisements = _meshClient! + .LeaseCollectionAsync(_endpointId) .GetAwaiter() .GetResult(); - _advertisement = documents.FirstOrDefault(document => + _advertisement = advertisements.Handle.LatestAsync().GetAwaiter().GetResult().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 => @@ -588,7 +487,7 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId)) throw new InvalidOperationException( $"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for body transport."); - var mappedRoot = Path.GetDirectoryName(_replicaPath) ?? "."; + var mappedRoot = _cachePath; var networkBodies = _meshClient!.BodyProvider( _advertisement.ProviderId, _advertisement.ServiceId, @@ -923,14 +822,10 @@ namespace GameCult.Eve.UnityScene private void RefreshSurface() { - var surface = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { _advertisedSurface!.RecordRef }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }) + var surface = _meshClient! + .ReadAsync(_endpointId, _advertisedSurface!.RecordRef) .GetAwaiter() - .GetResult() - .FirstOrDefault() - ?? throw new InvalidOperationException("The advertised Eve surface record was not available."); + .GetResult(); PublishBaseSurface(surface); } @@ -1013,37 +908,24 @@ namespace GameCult.Eve.UnityScene component.Style); } - private void EnsureLiveSubscriptions() + private void EnsureLiveDocuments() { - if (_subscriptions != null && _entitySubscriptions != null) return; - _entityBody?.Dispose(); - _entityBody = null; - _inputCapability?.Dispose(); - _inputCapability = null; - _subscriptions?.Dispose(); - _entitySubscriptions?.Dispose(); - _subscriptions = null; - _entitySubscriptions = null; + if (_liveDocumentsReady) return; + DisposeLiveDocuments(); try { - _subscriptions = CreateSubscriptionClient(); - _entitySubscriptions = CreateSubscriptionClient(); + OpenLiveDocuments(); + _liveDocumentsReady = true; } catch { - _entityBody?.Dispose(); - _entityBody = null; - _inputCapability?.Dispose(); - _inputCapability = null; - _subscriptions?.Dispose(); - _entitySubscriptions?.Dispose(); - _subscriptions = null; - _entitySubscriptions = null; + DisposeLiveDocuments(); throw; } - _subscriptions.Changed += OnReplicatedDocumentChanged; - _entitySubscriptions.Changed += OnReplicatedDocumentChanged; + } + private void OpenLiveDocuments() + { var lowered = new EveUnitySceneSurfaceLowerer() .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); string entityViewPointer = lowered.PlayableWorld == null @@ -1065,140 +947,91 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException( "The playable world advertises an entity-view pointer without its logical body id."); _bodyResolver ??= CreateBodyResolver(); - var initialLayout = _entitySubscriptions.SubscribeAsync( - "eve-unity-entity-layout", - recordKeys: new[] { entityViewPointer }, - schemaIds: new[] { EveEntitySoaViewDocument.SchemaId }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult(); - foreach (var layout in initialLayout.OfType()) - QueueEntityView(layout); - - _entityBody = CultMesh.SubscribeLiveBodyAsync( - _entitySubscriptions, - _bodyResolver, - new CultMeshLiveBodySubscription( - "eve-unity-entity-body", - _runtimeId, - entityBodyId)) - .GetAwaiter().GetResult(); - if (_entityBody.HasValue) - QueueBodyPublication(_entityBody.Current); - _entityBody.Changed += QueueBodyPublication; - _entityBody.Removed += () => _liveDocuments.Enqueue(new InvalidOperationException( - $"The provider withdrew required entity body '{entityBodyId}'.")); - - if (fieldRefs.Length > 0) + LeaseDocument(entityViewPointer, QueueEntityView); + LeaseCollection(publication => { - var initialFields = _entitySubscriptions.SubscribeAsync( - "eve-unity-fields", - recordKeys: fieldRefs, - schemaIds: new[] { EveFieldsSchemas.Splats }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult(); - foreach (var fields in initialFields.OfType()) - _liveDocuments.Enqueue(fields); - } + if (string.Equals(publication.BodyId, entityBodyId, StringComparison.Ordinal)) + QueueBodyPublication(publication); + }); } - else if (fieldRefs.Length > 0) - { - var initial = _entitySubscriptions.SubscribeAsync( - "eve-unity-fields", - recordKeys: fieldRefs, - schemaIds: new[] { EveFieldsSchemas.Splats }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult(); - foreach (var fields in initial.OfType()) - _liveDocuments.Enqueue(fields); - } - _subscriptions.SubscribeAsync( - "eve-unity-surface", - recordKeys: new[] { _advertisedSurface!.RecordRef }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }, - includeSnapshot: false) - .GetAwaiter().GetResult(); + foreach (var fieldRef in fieldRefs) + LeaseDocument(fieldRef, fields => _liveDocuments.Enqueue(fields)); + + LeaseDocument( + _advertisedSurface!.RecordRef, + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(_advertisedSurface.RecordRef, surface)), + publishInitial: false); var embeddedSlots = EnumerateEmbeddedDocuments(CurrentSurfaceDocument.SurfaceDocument.Surface.Root) .Where(slot => string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal)) .Where(slot => !string.IsNullOrWhiteSpace(slot.DocumentId)) .GroupBy(slot => slot.DocumentId, StringComparer.Ordinal) .Select(group => group.First()) .ToArray(); - for (var index = 0; index < embeddedSlots.Length; index++) + foreach (var slot in embeddedSlots) { - var slot = embeddedSlots[index]; - var initialEmbedded = _subscriptions.SubscribeAsync( - $"eve-unity-embedded-surface-{index}", - recordKeys: new[] { slot.DocumentId }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult() - .OfType() - .FirstOrDefault(); - if (initialEmbedded != null) - PublishEmbeddedSurface(slot.DocumentId, initialEmbedded); + LeaseDocument( + slot.DocumentId, + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface))); } var inputCapabilityRef = FindComponentProp( CurrentSurfaceDocument.SurfaceDocument.Surface.Root, "inputCapability"); if (!string.IsNullOrWhiteSpace(inputCapabilityRef)) { - _inputCapability = _subscriptions - .SubscribeLiveValueAsync( - "eve-unity-input-capability", - inputCapabilityRef) - .GetAwaiter().GetResult(); - if (_inputCapability.HasValue) - CurrentInputCapability = _inputCapability.Current; - _inputCapability.Changed += document => _liveDocuments.Enqueue(document); - _inputCapability.Removed += () => _liveDocuments.Enqueue(new InvalidOperationException( - $"The provider withdrew required input capability '{inputCapabilityRef}'.")); + LeaseDocument( + inputCapabilityRef, + document => _liveDocuments.Enqueue(document)); } var assetCatalogRef = RequireWorldInteraction().AssetManifestRecordRef; if (!string.IsNullOrWhiteSpace(assetCatalogRef)) { - _subscriptions.SubscribeAsync( - "eve-unity-asset-catalog", - recordKeys: new[] { assetCatalogRef }, - schemaIds: new[] { EveAssetCatalogDocument.SchemaId }, - includeSnapshot: false) - .GetAwaiter().GetResult(); + LeaseDocument( + assetCatalogRef, + document => _liveDocuments.Enqueue(document), + publishInitial: false); } - _subscriptions.SubscribeAsync( - "eve-unity-receipts", - schemaIds: new[] { EveCommandReceiptDocument.SchemaId }, - includeSnapshot: false, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) + LeaseCollection( + receipt => _liveDocuments.Enqueue(receipt), + includeInitialSnapshot: false); + } + + private void DisposeLiveDocuments() + { + foreach (var watch in _documentWatches) watch.Dispose(); + _documentWatches.Clear(); + foreach (var lease in _documentLeases) lease.Dispose(); + _documentLeases.Clear(); + _liveDocumentsReady = false; + } + + private void LeaseDocument( + string recordKey, + Action publish, + bool publishInitial = true) + where TDocument : class + { + var lease = _meshClient!.LeaseDocumentAsync(_endpointId, recordKey) .GetAwaiter().GetResult(); + _documentLeases.Add(lease); + if (publishInitial) publish(lease.Handle.Latest()); + _documentWatches.Add(lease.Handle.Watch(publish)); } - private CultNetDatabaseSubscriptionClient CreateSubscriptionClient() + private void LeaseCollection( + Action publish, + bool includeInitialSnapshot = true) + where TDocument : class { - 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 lease = _meshClient!.LeaseCollectionAsync(_endpointId, includeInitialSnapshot) + .GetAwaiter().GetResult(); + _documentLeases.Add(lease); + if (includeInitialSnapshot) + foreach (var document in lease.Handle.LatestAsync().GetAwaiter().GetResult()) + publish(document); + _documentWatches.Add(lease.Handle.WatchChanges(change => { - client.Dispose(); - throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'."); - } - return new CultNetDatabaseSubscriptionClient(client, _node!.Database.Cache, _networkRegistry!); - } - - private void OnReplicatedDocumentChanged(CultNetReplicatedDocumentChange change) - { - TraceHotState($"change subscription={change.SubscriptionId} kind={change.ChangeKind} document={change.Document?.GetType().Name ?? "null"}"); - if (change.Document is EveEntitySoaViewDocument entityView) - QueueEntityView(entityView); - else if (change.Document is CultMeshBodyPublicationDocument publication) - QueueBodyPublication(publication); - else if (change.Document is EveSurfaceDocument surface) - _liveDocuments.Enqueue(new PendingSurfaceDocument(change.RecordKey, surface)); - else if (change.Document is EveAssetCatalogDocument or - EveCommandReceiptDocument or EveFieldsSplatsDocument) - _liveDocuments.Enqueue(change.Document); + if (change.Document != null) publish(change.Document); + })); } private static IEnumerable EnumerateEmbeddedDocuments(EveSurfaceComponent component) @@ -1234,15 +1067,11 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) return; - var catalog = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { interaction.AssetManifestRecordRef }, - schemaIds: new[] { EveAssetCatalogDocument.SchemaId }) + var catalog = _meshClient! + .ReadAsync(_endpointId, interaction.AssetManifestRecordRef) .GetAwaiter() - .GetResult() - .FirstOrDefault(); - if (catalog != null) - PublishAssetCatalog(catalog); + .GetResult(); + PublishAssetCatalog(catalog); } private void PublishAssetCatalog(EveAssetCatalogDocument catalog) @@ -1328,14 +1157,9 @@ namespace GameCult.Eve.UnityScene EnsureBundleLoaded(dependency); var groupElapsed = Stopwatch.StartNew(); - var descriptor = _snapshot!.FetchDocumentsAsync( - recordKeys: new[] { uri }, - schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactManifest }) + var descriptor = _meshClient!.ReadAsync(_endpointId, uri) .GetAwaiter() - .GetResult() - .FirstOrDefault(); - if (descriptor == null) - throw new InvalidOperationException($"Provider asset bundle manifest '{uri}' was not available."); + .GetResult(); TraceStartup("asset-manifest", groupElapsed); var bundle = LoadVerifiedBundle(descriptor, variant); TraceStartup("asset-transfer-and-bundle-load", groupElapsed); @@ -1448,8 +1272,10 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(cacheRoot)) cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets"); _assetBodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); + _contentState ??= new CultCache( + CultMesh.CreateCultCacheDocumentRegistry(typeof(CultMeshContentTransferStateDocument))); _contentTransfer = new CultMeshContentTransferService( - _node!.Cache, + _contentState, new[] { _meshClient!.ContentProvider( 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 a9e263c..f93be26 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -9,6 +9,7 @@ using GameCult.Eve.PluginFields; using GameCult.Eve.UnityScene.Fields; using GameCult.Mesh; using UnityEngine; +using UnityEngine.Serialization; #nullable enable @@ -33,7 +34,8 @@ namespace GameCult.Eve.UnityScene [SerializeField] private string surfaceFilter = ""; [SerializeField] private string verseFilter = ""; [SerializeField] private string surfaceKind = "interactive-world"; - [SerializeField] private string replicaPath = ""; + [FormerlySerializedAs("replicaPath")] + [SerializeField] private string cacheDirectory = ""; [SerializeField] private string runtimeId = "eve-unity"; private EveUnityCultMeshLiveProviderTransport? _transport; @@ -76,7 +78,7 @@ namespace GameCult.Eve.UnityScene public void Configure( string endpoint, - string replicaStorePath = "", + string localCacheDirectory = "", string providerId = "", string surfaceId = "", string verseId = "", @@ -87,7 +89,7 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); rendezvousEndpoint = endpoint ?? ""; - replicaPath = replicaStorePath ?? ""; + cacheDirectory = localCacheDirectory ?? ""; providerFilter = providerId ?? ""; surfaceFilter = surfaceId ?? ""; verseFilter = verseId ?? ""; @@ -215,12 +217,13 @@ namespace GameCult.Eve.UnityScene verseFilter); TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); elapsed.Restart(); - var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath) - ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc") - : replicaPath; + var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) + ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") + : cacheDirectory; _transport = new EveUnityCultMeshLiveProviderTransport( - resolvedReplicaPath, - Selection.Endpoint, + resolvedCachePath, + Selection.RendezvousEndpoint, + Selection.EndpointId, Selection.ProviderId, Selection.SurfaceId, runtimeId); 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 11438de..91b9aef 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -13,20 +13,23 @@ namespace GameCult.Eve.UnityScene public sealed class EveUnityCultMeshProviderSelection { public EveUnityCultMeshProviderSelection( - string endpoint, + string rendezvousEndpoint, + string endpointId, string verseId, string providerId, string surfaceId, string surfaceKind) { - Endpoint = endpoint ?? ""; + RendezvousEndpoint = rendezvousEndpoint ?? ""; + EndpointId = endpointId ?? ""; VerseId = verseId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; } - public string Endpoint { get; } + public string RendezvousEndpoint { get; } + public string EndpointId { get; } public string VerseId { get; } public string ProviderId { get; } public string SurfaceId { get; } @@ -35,11 +38,6 @@ namespace GameCult.Eve.UnityScene public sealed class EveUnityCultMeshProviderDiscovery { - private static readonly Type[] AdvertisementDocumentTypes = - { - typeof(EveProviderAdvertisementDocument) - }; - public EveUnityCultMeshProviderSelection Discover( string rendezvousEndpoint, string providerId = "", @@ -80,22 +78,31 @@ namespace GameCult.Eve.UnityScene 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)) + .Where(verse => !string.IsNullOrWhiteSpace(verse.VerseId) && + (verse.DiscoveryEndpoints ?? Array.Empty()).Any(endpoint => + !string.IsNullOrWhiteSpace(endpoint))) + .GroupBy(verse => verse.VerseId, StringComparer.Ordinal) + .Select(group => group.First()) .ToArray(); if (candidates.Length == 0) throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints."); var failures = new List(); var observed = new List(); + using var mesh = new CultMeshClient(new CultMeshClientOptions + { + RendezvousEndpoints = new[] { rendezvousEndpoint } + }); foreach (var candidate in candidates) { try { - var advertisements = await FetchAdvertisementsAsync(candidate.Endpoint); + using var advertisementsLease = await mesh + .LeaseCollectionAsync(candidate.VerseId) + .ConfigureAwait(false); + var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false); observed.AddRange(advertisements.Select(document => - $"{candidate.Endpoint}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); + $"{candidate.VerseId}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); var advertisement = advertisements .Where(document => string.IsNullOrWhiteSpace(providerId) || string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) @@ -113,15 +120,16 @@ namespace GameCult.Eve.UnityScene continue; return new EveUnityCultMeshProviderSelection( - candidate.Endpoint, - candidate.Verse.VerseId, + rendezvousEndpoint, + advertisement.Document.ProviderId, + candidate.VerseId, advertisement.Document.ProviderId, advertisement.Surface.SurfaceId, advertisement.Surface.SurfaceKind); } catch (Exception error) { - failures.Add($"{candidate.Endpoint}: {error.Message}"); + failures.Add($"{candidate.VerseId}: {error.Message}"); } } @@ -131,28 +139,5 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}"); } - private static async Task FetchAdvertisementsAsync(string endpoint) - { - var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes); - var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry); - var snapshot = CultMesh.SnapshotEndpoint( - endpoint, - new CultMeshSnapshotEndpointOptions - { - Context = CultMesh.Verse("eve.discovery", "eve-unity").Context, - DocumentRegistry = networkRegistry, - Request = new CultMeshSnapshotRequestOptions - { - ShardId = "provider", - ShardEpoch = 1, - ConnectTimeout = TimeSpan.FromSeconds(5), - ResponseTimeout = TimeSpan.FromSeconds(10), - MessageIdPrefix = "eve-unity-discovery", - RudpRuntimeId = "eve-unity.discovery", - RudpMaxFragmentBytes = 1024 - } - }); - return (await snapshot.FetchDocumentsAsync()).ToArray(); - } } } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index ddc3e21..7cf72b6 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -14,6 +14,39 @@ namespace GameCult.Eve.UnityScene.Tests { public sealed class EveUnityCultMeshAssetLookupTests { + [Test] + public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute() + { + var selection = new EveUnityCultMeshProviderSelection( + "cultnet+tcp://odin.example:3075", + "aetheria.daemon", + "aetheria.public", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world"); + + Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075")); + Assert.That(selection.EndpointId, Is.EqualTo("aetheria.daemon")); + Assert.That(selection.VerseId, Is.EqualTo("aetheria.public")); + Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null); + } + + [Test] + public void LiveTransportOwnsOneCultMeshClientAndNoDirectSnapshotReplica() + { + var fields = typeof(EveUnityCultMeshLiveProviderTransport) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic); + var names = Array.ConvertAll(fields, field => field.Name); + + CollectionAssert.Contains(names, "_meshClient"); + CollectionAssert.DoesNotContain(names, "_node"); + CollectionAssert.DoesNotContain(names, "_snapshot"); + CollectionAssert.DoesNotContain(names, "_networkRegistry"); + CollectionAssert.DoesNotContain(names, "_replicaShard"); + CollectionAssert.DoesNotContain(names, "_subscriptions"); + CollectionAssert.DoesNotContain(names, "_entitySubscriptions"); + } + [Test] public void UnavailableTcpRendezvousIsReportedAsRetryablePreparationFailure() { @@ -146,6 +179,7 @@ namespace GameCult.Eve.UnityScene.Tests Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"), "cultnet+tcp://127.0.0.1:1", "provider", + "provider", "surface"); var variant = new EveAssetVariant( "unity-scene", 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 a65771c..acae3fe 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -25,9 +25,10 @@ namespace GameCult.Eve.UnityScene.Tests Assert.DoesNotThrow(() => { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "aetheria", + "aetheria", "aetheria.pilot"); }); } @@ -59,9 +60,10 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportReusesStableLayoutForNewBodyPublications() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "aetheria", + "aetheria", "aetheria.pilot"); var view = EntityLeaseDocument(); var publication = BodyPublication(view, view.Sequence + 1); @@ -93,9 +95,10 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportPairsBodyPublicationThatArrivesBeforeItsLayout() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "aetheria", + "aetheria", "aetheria.pilot"); var view = EntityLeaseDocument(); var publication = BodyPublication(view, view.Sequence); @@ -121,9 +124,10 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportKeepsOnlyLatestPendingRealtimeGenerationPerBody() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "provider", + "provider", "provider.pilot"); var enqueue = typeof(EveUnityCultMeshLiveProviderTransport) .GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -150,9 +154,10 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "provider", + "provider", "provider.pilot"); var view = EntityLeaseDocument(); view.Sequence = 0; @@ -231,14 +236,20 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void LiveTransportDoesNotRegisterBodyBytesAsSnapshotDocuments() + public void LiveTransportHasNoSnapshotWireRegistryForBodyBytes() { - var wireTypes = (Type[])typeof(EveUnityCultMeshLiveProviderTransport) - .GetField("WireDocumentTypes", BindingFlags.NonPublic | BindingFlags.Static)! - .GetValue(null)!; + var transport = typeof(EveUnityCultMeshLiveProviderTransport); + var wireRegistry = transport.GetField( + "WireDocumentTypes", + BindingFlags.NonPublic | BindingFlags.Static); + var fieldTypes = transport + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) + .Select(field => field.FieldType) + .ToArray(); - Assert.That(wireTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False); - Assert.That(wireTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False); + Assert.That(wireRegistry, Is.Null); + Assert.That(fieldTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False); + Assert.That(fieldTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False); } [Test] diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index c84f62f..980c07b 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.103", + "version": "0.3.104", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index 25aa7cb..afcaaee 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -40,7 +40,7 @@ $mapCapturePath = Join-Path $outputRoot "aetheria-daemon-map.png" $factsPath = Join-Path $outputRoot "witness-facts.json" $providerReadyPath = Join-Path $outputRoot "provider-ready.txt" $witnessPath = Join-Path $outputRoot "runtime-witness.json" -$replicaPath = Join-Path $outputRoot "eve-unity-replica.cc" +$clientCachePath = Join-Path $outputRoot "eve-unity-cache" $statePath = Join-Path $outputRoot "aetheria-witness-state.cc" $providerBundleDirectory = Join-Path $AetheriaRoot "Build\EveAssets\StandaloneWindows64" $daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" @@ -86,9 +86,7 @@ $witnessProfile = if ($observedCacheState -eq "cold") { } $witnessStartedAt = [DateTimeOffset]::UtcNow foreach ($ephemeralPath in @( - $replicaPath, - "$replicaPath.records", - "$replicaPath.cultmesh", + $clientCachePath, $statePath, "$statePath.records", "$statePath.cultmesh", @@ -211,7 +209,7 @@ $env:AETHERIA_TRACE_STARTUP_PHASES = "1" $env:EVEUNITY_RENDEZVOUS_ENDPOINT = "cultnet+tcp://127.0.0.1:$Port" $env:EVEUNITY_PROVIDER_ID = "aetheria" Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue -$env:EVEUNITY_REPLICA_PATH = $replicaPath +$env:EVEUNITY_CACHE_DIRECTORY = $clientCachePath $env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath $env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH = $mapCapturePath $env:EVEUNITY_DISABLE_AUTO_LAUNCHER = "1" @@ -474,6 +472,6 @@ try { finally { if ($null -ne $unity -and -not $unity.HasExited) { Stop-Process -Id $unity.Id -Force -ErrorAction SilentlyContinue } if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force } - Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH, Env:EVEUNITY_DISABLE_AUTO_LAUNCHER, Env:EVEUNITY_ASSET_CACHE_PATH, Env:EVEUNITY_WITNESS_FACTS_PATH, Env:EVEUNITY_PROVIDER_READY_PATH, Env:EVEUNITY_WITNESS_PROFILE, Env:EVEUNITY_WITNESS_GAMEPLAY_SCENARIO -ErrorAction SilentlyContinue + Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_CACHE_DIRECTORY, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH, Env:EVEUNITY_DISABLE_AUTO_LAUNCHER, Env:EVEUNITY_ASSET_CACHE_PATH, Env:EVEUNITY_WITNESS_FACTS_PATH, Env:EVEUNITY_PROVIDER_READY_PATH, Env:EVEUNITY_WITNESS_PROFILE, Env:EVEUNITY_WITNESS_GAMEPLAY_SCENARIO -ErrorAction SilentlyContinue Remove-Item Env:AETHERIA_TRACE_EVE_SNAPSHOTS, Env:AETHERIA_TRACE_CLIENT_TRANSPORT -ErrorAction SilentlyContinue } diff --git a/scripts/run-interactive-aetheria.ps1 b/scripts/run-interactive-aetheria.ps1 index 3451b20..51097c7 100644 --- a/scripts/run-interactive-aetheria.ps1 +++ b/scripts/run-interactive-aetheria.ps1 @@ -19,7 +19,7 @@ $daemonLog = Join-Path $outputRoot "daemon.log" $importLog = Join-Path $outputRoot "import.log" $importErrorLog = Join-Path $outputRoot "import.error.log" $state = Join-Path $outputRoot "aetheria.cc" -$replica = Join-Path $outputRoot "eve-unity-replica.cc" +$clientCache = Join-Path $outputRoot "eve-unity-cache" $assetCache = Join-Path $outputRoot "asset-cache" $exe = Join-Path $project "Build\Windows\EveUnity.exe" $importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj" @@ -32,7 +32,7 @@ if (Get-NetUDPEndpoint -LocalPort $Port -ErrorAction SilentlyContinue) { throw "UDP port $Port is already occupied." } New-Item -ItemType Directory -Force $outputRoot, $assetCache | Out-Null -foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $replica, "$replica.records", "$replica.cultmesh")) { +foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $clientCache)) { $resolved = [IO.Path]::GetFullPath($ephemeralPath) if (-not $resolved.StartsWith([IO.Path]::GetFullPath($outputRoot), [StringComparison]::OrdinalIgnoreCase)) { throw "Interactive cleanup escaped its artifact directory: $resolved" @@ -106,7 +106,7 @@ try { $env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port" $env:EVEUNITY_PROVIDER_ID = "aetheria" - $env:EVEUNITY_REPLICA_PATH = $replica + $env:EVEUNITY_CACHE_DIRECTORY = $clientCache $env:EVEUNITY_ASSET_CACHE_PATH = $assetCache Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue $client = Start-Process $exe -ArgumentList "-force-d3d11" -PassThru @@ -117,5 +117,5 @@ try { } finally { if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process $daemon.Id -Force } - Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue + Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_CACHE_DIRECTORY, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue } From 754be7e5157295a326dc4a1f245d46a95ddbefec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 21:21:13 +0200 Subject: [PATCH 04/45] Consume Eve-owned surface contracts --- TestProject/Packages/manifest.json | 2 +- TestProject/Packages/packages-lock.json | 10 +- .../Directory.Build.props | 7 - .../GameCult.Eve.Surface.csproj | 15 - .../GameCult.Eve.Surface.csproj.meta | 7 - packages/org.gamecult.eve.surface/README.md | 29 - .../org.gamecult.eve.surface/README.md.meta | 7 - .../org.gamecult.eve.surface/Runtime.meta | 8 - .../Runtime/EveAssetCatalogDocument.cs | 144 --- .../Runtime/EveAssetCatalogDocument.cs.meta | 2 - .../Runtime/EveEntitySoaViewDocument.cs | 96 -- .../Runtime/EveEntitySoaViewDocument.cs.meta | 11 - .../Runtime/EveInputCapabilityDocument.cs | 75 -- .../EveInputCapabilityDocument.cs.meta | 2 - .../Runtime/EveInputDeviceLayouts.cs | 55 -- .../Runtime/EveInputDeviceLayouts.cs.meta | 2 - .../Runtime/EveInputGestures.cs | 82 -- .../Runtime/EveInputGestures.cs.meta | 2 - .../Runtime/EveInventoryInteraction.cs | 82 -- .../Runtime/EveInventoryInteraction.cs.meta | 2 - .../Runtime/EveProviderDocuments.cs | 414 --------- .../Runtime/EveProviderDocuments.cs.meta | 2 - .../Runtime/EveSurfaceBuilder.cs | 874 ------------------ .../Runtime/EveSurfaceBuilder.cs.meta | 2 - .../Runtime/EveSurfaceDocument.cs | 538 ----------- .../Runtime/EveSurfaceDocument.cs.meta | 2 - .../Runtime/GameCult.Eve.Surface.asmdef | 21 - .../Runtime/GameCult.Eve.Surface.asmdef.meta | 7 - .../org.gamecult.eve.surface/package.json | 29 - .../package.json.meta | 7 - .../eve-runtime-capability.json | 8 +- .../org.gamecult.eve.unity-scene/package.json | 4 +- .../eve-runtime-capability.json | 6 +- .../package.json | 4 +- scripts/run-package-tests.ps1 | 10 + .../.gitignore | 1 - ...eSurfaceSerializationCompatibilityTests.cs | 262 ------ ...ult.Eve.Surface.Serialization.Tests.csproj | 20 - 38 files changed, 27 insertions(+), 2824 deletions(-) delete mode 100644 packages/org.gamecult.eve.surface/Directory.Build.props delete mode 100644 packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj delete mode 100644 packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta delete mode 100644 packages/org.gamecult.eve.surface/README.md delete mode 100644 packages/org.gamecult.eve.surface/README.md.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs delete mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta delete mode 100644 packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef delete mode 100644 packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta delete mode 100644 packages/org.gamecult.eve.surface/package.json delete mode 100644 packages/org.gamecult.eve.surface/package.json.meta delete mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore delete mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs delete mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj diff --git a/TestProject/Packages/manifest.json b/TestProject/Packages/manifest.json index 1f31a7a..157057e 100644 --- a/TestProject/Packages/manifest.json +++ b/TestProject/Packages/manifest.json @@ -7,7 +7,7 @@ "com.unity.modules.uielements": "1.0.0", "org.gamecult.cultlib": "file:../../artifacts/test-dependencies/org.gamecult.cultlib", "org.gamecult.eve.plugin-fields": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields", - "org.gamecult.eve.surface": "file:../../packages/org.gamecult.eve.surface", + "org.gamecult.eve.surface": "file:../../../Eve/packages/org.gamecult.eve.surface", "org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene", "org.gamecult.eve.unity-uitoolkit": "file:../../packages/org.gamecult.eve.unity-uitoolkit" }, diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 7b602ba..8046efd 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -133,11 +133,11 @@ } }, "org.gamecult.eve.surface": { - "version": "file:../../packages/org.gamecult.eve.surface", + "version": "file:../../../Eve/packages/org.gamecult.eve.surface", "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.8" + "org.gamecult.cultlib": "1.0.43" } }, "org.gamecult.eve.unity-scene": { @@ -147,8 +147,8 @@ "dependencies": { "org.gamecult.cultlib": "1.0.43", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.4", - "org.gamecult.eve.unity-uitoolkit": "0.1.2", + "org.gamecult.eve.surface": "0.3.0", + "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" @@ -159,7 +159,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.eve.surface": "0.2.4" + "org.gamecult.eve.surface": "0.3.0" } }, "com.unity.modules.assetbundle": { diff --git a/packages/org.gamecult.eve.surface/Directory.Build.props b/packages/org.gamecult.eve.surface/Directory.Build.props deleted file mode 100644 index 994fcb6..0000000 --- a/packages/org.gamecult.eve.surface/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - $(MSBuildThisFileDirectory)bin~\ - $(MSBuildThisFileDirectory)obj~\ - $(MSBuildThisFileDirectory)obj~\ - - diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj deleted file mode 100644 index f07f7e8..0000000 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - netstandard2.1 - enable - latest - enable - Portable Eve surface and provider contracts. - $(MSBuildThisFileDirectory)..\..\..\CultLib - - - - - - - diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta deleted file mode 100644 index 751910b..0000000 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ce2d4913d64690c44ae68935014fd79d -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md deleted file mode 100644 index eb65c17..0000000 --- a/packages/org.gamecult.eve.surface/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Eve Surface Contract - -This package defines the retained CultUI surface document that every Eve -runtime lowers. - -Components own normal child layout through `Children`, live CultMesh value -bindings through `StateBindings`, and nested synced document regions through -`EmbeddedDocuments`. An embedded document slot names the child document id, -schema, presentation kind, and route hint; renderers resolve that child through -CultMesh instead of rebuilding it with runtime-local projection code. - -See `../../docs/surface-contract-v1.md` for the full contract and -`../../web/fixtures/cultui-embedded-surface.json` for the parity fixture. -Runtime discovery and required feature coverage live in -`../../tools/parity/parity-manifest.json`; active GUI runtimes must list -`embeddedDocuments` as a supported feature and require the `embedded-surface` -fixture. - -Verification entrypoints: - -- Browser/TypeScript: `node --test web\eve-dsl.test.mjs` -- Semantic runtime matrix: `powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1` -- Flutter GUI contract, when Flutter is installed: `flutter test --plain-name embedded_surface_fixture_contract` -- Rust/CultMesh document sync: run `cargo test rust_preserves_cultui_embedded_surface_slots_through_typed_document_sync` from `E:/Projects/CultLib/packages/cultnet-rs` - -The parity manifest is the discovery source for web, Flutter desktop/Linux/Android, -iOS/UIKit, Android/Kotlin, Unity UI Toolkit, and Rust contract coverage. Do not -add a new renderer claim without adding its nested-surface fixture requirement -there. diff --git a/packages/org.gamecult.eve.surface/README.md.meta b/packages/org.gamecult.eve.surface/README.md.meta deleted file mode 100644 index 4bc751a..0000000 --- a/packages/org.gamecult.eve.surface/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: fe5aa27636bead047b2aecb14fdc59f2 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/Runtime.meta b/packages/org.gamecult.eve.surface/Runtime.meta deleted file mode 100644 index f150917..0000000 --- a/packages/org.gamecult.eve.surface/Runtime.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 2164f6fea1216214d895e7dc99c46ecf -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs deleted file mode 100644 index eba3b44..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.asset_catalog", SchemaId)] - [MessagePackObject] - public sealed class EveAssetCatalogDocument - { - public const string SchemaId = "gamecult.eve.asset_catalog.v1"; - - [SerializationConstructor] - public EveAssetCatalogDocument( - string schema, - string providerId, - string catalogId, - long version, - string updatedAtUtc, - EveAssetCatalogEntry[] assets) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - CatalogId = catalogId ?? ""; - Version = version; - UpdatedAtUtc = updatedAtUtc ?? ""; - Assets = assets ?? Array.Empty(); - } - - public EveAssetCatalogDocument( - string providerId, - string catalogId, - long version, - string updatedAtUtc, - EveAssetCatalogEntry[] assets) - : this(SchemaId, providerId, catalogId, version, updatedAtUtc, assets) - { - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ProviderId { get; } - [Key(2)] public string CatalogId { get; } - [Key(3)] public long Version { get; } - [Key(4)] public string UpdatedAtUtc { get; } - [Key(5)] public EveAssetCatalogEntry[] Assets { get; } - } - - [MessagePackObject] - public sealed class EveAssetCatalogEntry - { - [SerializationConstructor] - public EveAssetCatalogEntry( - string assetRef, - string assetKind, - EveAssetVariant[] variants, - Dictionary metadata) - { - AssetRef = assetRef ?? ""; - AssetKind = assetKind ?? ""; - Variants = variants ?? Array.Empty(); - Metadata = metadata == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(metadata, StringComparer.Ordinal); - } - - public EveAssetCatalogEntry( - string assetRef, - string assetKind, - EveAssetVariant[] variants, - IReadOnlyDictionary? metadata = null) - : this( - assetRef, - assetKind, - variants, - metadata == null ? new Dictionary() : new Dictionary(metadata)) - { - } - - [Key(0)] public string AssetRef { get; } - [Key(1)] public string AssetKind { get; } - [Key(2)] public EveAssetVariant[] Variants { get; } - [Key(3)] public Dictionary Metadata { get; } - } - - [MessagePackObject] - public sealed class EveAssetVariant - { - [SerializationConstructor] - public EveAssetVariant( - string runtimeId, - string platform, - string format, - string uri, - string contentHash, - long sizeBytes, - string assetKey, - Dictionary metadata) - { - RuntimeId = runtimeId ?? ""; - Platform = platform ?? ""; - Format = format ?? ""; - Uri = uri ?? ""; - ContentHash = contentHash ?? ""; - SizeBytes = sizeBytes; - AssetKey = assetKey ?? ""; - Metadata = metadata == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(metadata, StringComparer.Ordinal); - } - - public EveAssetVariant( - string runtimeId, - string platform, - string format, - string uri, - string contentHash, - long sizeBytes, - string assetKey, - IReadOnlyDictionary? metadata = null) - : this( - runtimeId, - platform, - format, - uri, - contentHash, - sizeBytes, - assetKey, - metadata == null ? new Dictionary() : new Dictionary(metadata)) - { - } - - [Key(0)] public string RuntimeId { get; } - [Key(1)] public string Platform { get; } - [Key(2)] public string Format { get; } - [Key(3)] public string Uri { get; } - [Key(4)] public string ContentHash { get; } - [Key(5)] public long SizeBytes { get; } - [Key(6)] public string AssetKey { get; } - [Key(7)] public Dictionary Metadata { get; } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta deleted file mode 100644 index 0af6487..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8f53654f7c98d894588127ba001f11b4 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs deleted file mode 100644 index d5148f1..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using GameCult.Caching; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.entity_soa_view", SchemaId)] - [MessagePackObject] - public sealed class EveEntitySoaViewDocument - { - public const string SchemaId = "gamecult.eve.entity_soa_view.v2"; - - [Key(0)] public string Schema { get; set; } = SchemaId; - [Key(1)] public string ProviderId { get; set; } = ""; - [Key(2)] public string ViewId { get; set; } = ""; - [Key(3)] public string PublishedAtUtc { get; set; } = ""; - [Key(4)] public string BodySchemaId { get; set; } = ""; - [Key(5)] public int LayoutVersion { get; set; } - [Key(6)] public long ProducerEpoch { get; set; } - [Key(7)] public long Sequence { get; set; } - [Key(8)] public int Capacity { get; set; } - [Key(9)] public EveEntitySoaBuffer[] Buffers { get; set; } = Array.Empty(); - [Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty(); - [Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty(); - [Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty(); - [Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty(); - [Key(14)] public long FrameId { get; set; } - } - - [MessagePackObject] - public sealed class EveEntitySoaBuffer - { - [Key(0)] public string BufferId { get; set; } = ""; - [Key(1)] public long ByteOffset { get; set; } - [Key(2)] public long ByteLength { get; set; } - } - - [MessagePackObject] - public sealed class EveEntitySoaColumn - { - [Key(0)] public string ColumnId { get; set; } = ""; - [Key(1)] public string Semantic { get; set; } = ""; - [Key(2)] public string BufferId { get; set; } = ""; - [Key(3)] public string ScalarType { get; set; } = ""; - [Key(4)] public long ByteOffset { get; set; } - [Key(5)] public int ElementStride { get; set; } - [Key(6)] public int ElementCount { get; set; } - [Key(7)] public string Unit { get; set; } = ""; - [Key(8)] public string CoordinateSpace { get; set; } = ""; - } - - [MessagePackObject] - public sealed class EveEntitySoaDirtyRange - { - [Key(0)] public string ColumnId { get; set; } = ""; - [Key(1)] public int StartIndex { get; set; } - [Key(2)] public int Count { get; set; } - [Key(3)] public long Sequence { get; set; } - } - - [MessagePackObject] - public sealed class EveEntityIdentity - { - [Key(0)] public int Index { get; set; } - [Key(1)] public string EntityId { get; set; } = ""; - [Key(2)] public string EntityKind { get; set; } = ""; - [Key(3)] public string Label { get; set; } = ""; - [Key(4)] public string Faction { get; set; } = ""; - [Key(5)] public bool Selectable { get; set; } - [Key(6)] public bool Controllable { get; set; } - [Key(7)] public string AssetRef { get; set; } = ""; - } - - [MessagePackObject] - public sealed class EveEntityRenderGroup - { - [Key(0)] public int GroupId { get; set; } - [Key(1)] public string MeshAssetRef { get; set; } = ""; - [Key(2)] public string MaterialAssetRef { get; set; } = ""; - [Key(3)] public int SubMeshIndex { get; set; } - [Key(4)] public int Layer { get; set; } - [Key(5)] public int InstanceCount { get; set; } - [Key(6)] public float DefaultScale { get; set; } = 1f; - [Key(7)] public float BoundsCenterX { get; set; } - [Key(8)] public float BoundsCenterY { get; set; } - [Key(9)] public float BoundsCenterZ { get; set; } - [Key(10)] public float BoundsSizeX { get; set; } - [Key(11)] public float BoundsSizeY { get; set; } - [Key(12)] public float BoundsSizeZ { get; set; } - [Key(13)] public string ShadowMode { get; set; } = "on"; - [Key(14)] public bool ReceiveShadows { get; set; } = true; - [Key(15)] public int Lod { get; set; } = -1; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta deleted file mode 100644 index b2882e9..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8e87e1be783846508efea45787f2d45c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs deleted file mode 100644 index ed8b486..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs +++ /dev/null @@ -1,75 +0,0 @@ -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); - [Key(8)] public EveInputValueDocument? InputValue { get; set; } - [Key(9)] public string IconRef { get; set; } = ""; - [Key(10)] public bool ActionBar { get; set; } - } - - [MessagePackObject] - public sealed class EveInputValueDocument - { - [Key(0)] public string Model { get; set; } = ""; - [Key(1)] public string PayloadKey { get; set; } = ""; - [Key(2)] public string[] PayloadKeys { get; set; } = Array.Empty(); - [Key(3)] public double? CurrentValue { get; set; } - [Key(4)] public double? MinimumValue { get; set; } - [Key(5)] public double? MaximumValue { get; set; } - [Key(6)] public double? StepValue { get; set; } - [Key(7)] public string Unit { get; set; } = ""; - } - - [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 deleted file mode 100644 index f13f766..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4c297c7dbb64a0f49a7f805e770c5e55 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs deleted file mode 100644 index 35425a3..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace GameCult.Eve.Surface -{ - public sealed class EveInputDeviceLayout - { - public string LayoutId { get; set; } = ""; - public string DeviceClass { get; set; } = ""; - public IReadOnlyList Controls { get; set; } = Array.Empty(); - public bool SupportsDirectionalStrings { get; set; } - - public static EveInputDeviceLayout AnsiKeyboardMouse() => new EveInputDeviceLayout - { - LayoutId = "keyboard.ansi-104+mouse.standard", - DeviceClass = "keyboard-mouse", - Controls = new[] - { - Key("keyboard.escape", "Esc", 0, 0, 1.25f), Key("keyboard.f1", "F1", 2, 0), Key("keyboard.f2", "F2", 3, 0), - Key("keyboard.1", "1", 1, 1), Key("keyboard.2", "2", 2, 1), Key("keyboard.3", "3", 3, 1), Key("keyboard.4", "4", 4, 1), Key("keyboard.5", "5", 5, 1), - Key("keyboard.tab", "Tab", 0, 2, 1.5f), Key("keyboard.q", "Q", 1.5f, 2), Key("keyboard.w", "W", 2.5f, 2), Key("keyboard.e", "E", 3.5f, 2), Key("keyboard.r", "R", 4.5f, 2), - Key("keyboard.a", "A", 1.75f, 3), Key("keyboard.s", "S", 2.75f, 3), Key("keyboard.d", "D", 3.75f, 3), Key("keyboard.f", "F", 4.75f, 3), - Key("keyboard.leftShift", "Shift", 0, 4, 2.25f), Key("keyboard.space", "Space", 3.75f, 5, 6.25f), - Key("mouse.primary", "Mouse 1", 18, 1), Key("mouse.secondary", "Mouse 2", 19, 1), Key("mouse.middle", "Mouse 3", 18.5f, 2) - } - }; - - public static EveInputDeviceLayout StandardGamepad() => new EveInputDeviceLayout - { - LayoutId = "gamepad.standard", - DeviceClass = "gamepad", - SupportsDirectionalStrings = true, - Controls = new[] - { - Key("gamepad.leftStick", "Left Stick", 2, 3, 2), Key("gamepad.rightStick", "Right Stick", 8, 4, 2), - Key("gamepad.dpad.up", "Up", 1, 1), Key("gamepad.dpad.left", "Left", 0, 2), Key("gamepad.dpad.right", "Right", 2, 2), Key("gamepad.dpad.down", "Down", 1, 3), - Key("gamepad.buttonSouth", "A / Cross", 10, 3), Key("gamepad.buttonEast", "B / Circle", 11, 2), Key("gamepad.buttonWest", "X / Square", 9, 2), Key("gamepad.buttonNorth", "Y / Triangle", 10, 1), - Key("gamepad.leftTrigger", "LT / L2", 2, 0, 2), Key("gamepad.rightTrigger", "RT / R2", 9, 0, 2) - } - }; - - private static EveInputLayoutControl Key(string id, string label, float x, float y, float width = 1) => - new EveInputLayoutControl { Control = id, Label = label, X = x, Y = y, Width = width, Height = 1 }; - } - - public sealed class EveInputLayoutControl - { - public string Control { get; set; } = ""; - public string Label { get; set; } = ""; - public float X { get; set; } - public float Y { get; set; } - public float Width { get; set; } = 1; - public float Height { get; set; } = 1; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta deleted file mode 100644 index 0d7a9a2..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8a2a1258551e42a9831a3e4f9483d928 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs deleted file mode 100644 index a318066..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace GameCult.Eve.Surface -{ - public sealed class EveInputGesture - { - public string Kind { get; set; } = "direct"; - public IReadOnlyList Controls { get; set; } = Array.Empty(); - public int MaxStepIntervalMs { get; set; } = 650; - public string CompletionControl { get; set; } = ""; - } - - public sealed class EveInputBinding - { - public string BindingId { get; set; } = ""; - public string ActionId { get; set; } = ""; - public EveInputGesture Gesture { get; set; } = new EveInputGesture(); - public bool ActionBar { get; set; } - } - - public sealed class EveInputGestureResolver - { - private readonly IReadOnlyList _bindings; - private readonly HashSet _pressed = new HashSet(StringComparer.Ordinal); - private readonly Dictionary _sequences = new Dictionary(StringComparer.Ordinal); - - public EveInputGestureResolver(IEnumerable bindings) - { - _bindings = (bindings ?? Array.Empty()).Where(binding => binding != null).ToArray(); - } - - public IReadOnlyList Press(string control, long timestampMs) - { - _pressed.Add(control ?? ""); - var actions = new HashSet(StringComparer.Ordinal); - foreach (var binding in _bindings) - { - var gesture = binding.Gesture ?? new EveInputGesture(); - if (gesture.Kind == "direct" && gesture.Controls.FirstOrDefault() == control) - actions.Add(binding.ActionId); - else if (gesture.Kind == "chord" && gesture.Controls.Count > 0 && gesture.Controls.All(_pressed.Contains)) - actions.Add(binding.ActionId); - else if (gesture.Kind == "sequence" && Advance(binding, control ?? "", timestampMs)) - actions.Add(binding.ActionId); - } - return actions.ToArray(); - } - - public void Release(string control) => _pressed.Remove(control ?? ""); - - public void Reset() - { - _pressed.Clear(); - _sequences.Clear(); - } - - private bool Advance(EveInputBinding binding, string control, long timestampMs) - { - var controls = binding.Gesture.Controls; - if (controls.Count == 0) return false; - _sequences.TryGetValue(binding.BindingId, out var previous); - var index = previous.Index > 0 && timestampMs - previous.TimestampMs <= Math.Max(50, binding.Gesture.MaxStepIntervalMs) ? previous.Index : 0; - var next = controls[index] == control ? index + 1 : controls[0] == control ? 1 : 0; - if (next == controls.Count) - { - _sequences.Remove(binding.BindingId); - return string.IsNullOrWhiteSpace(binding.Gesture.CompletionControl); - } - _sequences[binding.BindingId] = new SequenceState(next, timestampMs); - return false; - } - - private readonly struct SequenceState - { - public SequenceState(int index, long timestampMs) { Index = index; TimestampMs = timestampMs; } - public int Index { get; } - public long TimestampMs { get; } - } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta deleted file mode 100644 index 3abaaab..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e3c2941be243a754092ac2543d1e6bf7 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs deleted file mode 100644 index c8c53e9..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - public static class EveInventoryInteraction - { - public const string GridKind = "inventory.grid"; - public const string ItemKind = "inventory.item"; - public const string DragSessionKind = "inventory.drag_session"; - - public static bool TryCreateDropRequest( - EveSurfaceDocument document, - EveSurfaceComponent source, - EveSurfaceComponent target, - int destinationX, - int destinationY, - string clientId, - out EveSurfaceCommandRequest? request) - { - request = null; - if (document == null || source == null || target == null || - !string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) || - !string.Equals(target.Kind, GridKind, StringComparison.Ordinal)) - { - return false; - } - - var sourceKind = source.GetProp("sourceKind", source.GetProp("source")); - var command = target.GetProp($"dropCommand.{sourceKind}", target.GetProp("dropCommand")); - if (string.IsNullOrWhiteSpace(sourceKind) || string.IsNullOrWhiteSpace(command)) - return false; - - var sourceIndex = Int(source.GetProp("sourceIndex"), -1); - var targetIndex = Int(target.GetProp("targetIndex"), -1); - var targetKind = target.GetProp("targetKind"); - var payload = new Dictionary(StringComparer.Ordinal) - { - ["sourceKind"] = sourceKind, - ["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")), - ["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture), - ["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1) - .ToString(CultureInfo.InvariantCulture), - ["itemKey"] = source.GetProp("itemKey"), - ["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture), - ["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture), - ["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture), - ["destinationKind"] = targetKind, - ["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")), - ["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture), - ["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1) - .ToString(CultureInfo.InvariantCulture), - ["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture), - ["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture), - ["hasDestinationPosition"] = "true" - }; - var template = document.Commands.FirstOrDefault(candidate => - string.Equals(candidate.Command, command, StringComparison.Ordinal)); - var operation = template == null - ? CultMesh.OperationInvocation(command) - : CultMesh.OperationInvocation(template.Operation); - request = new EveSurfaceCommandRequest( - document.ProviderId, - document.Surface.Id, - operation, - CultMesh.OperationPayload(payload), - DateTimeOffset.UtcNow, - string.IsNullOrWhiteSpace(clientId) ? "unity-uitoolkit" : clientId); - return true; - } - - private static int Int(string value, int fallback) => - int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) - ? parsed - : fallback; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta deleted file mode 100644 index 57cb330..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 17fd4173e6e44b4b9c503d343021ffcb diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs deleted file mode 100644 index a52d436..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ /dev/null @@ -1,414 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; -using MessagePack.Formatters; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.provider_advertisement", SchemaId)] - [MessagePackObject] - [MessagePackFormatter(typeof(EveProviderAdvertisementCompatibilityFormatter))] - public sealed class EveProviderAdvertisementDocument - { - public const string SchemaId = "gamecult.eve.provider_advertisement.v1"; - - public EveProviderAdvertisementDocument( - string providerId, - string serviceId, - string verseId, - string title, - string kind, - string cultMeshAddress, - string updatedAtUtc, - EveProviderFreshness freshness, - IReadOnlyList schemas, - IReadOnlyList witnesses, - IReadOnlyList surfaces, - IReadOnlyList commands, - IReadOnlyList? authorizedBodyProducerIds = null) - : this(SchemaId, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands, authorizedBodyProducerIds) - { - } - - [SerializationConstructor] - public EveProviderAdvertisementDocument( - string schema, - string providerId, - string serviceId, - string verseId, - string title, - string kind, - string cultMeshAddress, - string updatedAtUtc, - EveProviderFreshness freshness, - IReadOnlyList schemas, - IReadOnlyList witnesses, - IReadOnlyList surfaces, - IReadOnlyList commands, - IReadOnlyList? authorizedBodyProducerIds = null) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - ServiceId = serviceId ?? ""; - VerseId = verseId ?? ""; - Title = title ?? ""; - Kind = kind ?? ""; - CultMeshAddress = cultMeshAddress ?? ""; - UpdatedAtUtc = updatedAtUtc ?? ""; - Freshness = freshness ?? new EveProviderFreshness("unknown", "", 0); - Schemas = schemas ?? Array.Empty(); - Witnesses = witnesses ?? Array.Empty(); - Surfaces = surfaces ?? Array.Empty(); - Commands = commands ?? Array.Empty(); - AuthorizedBodyProducerIds = authorizedBodyProducerIds ?? Array.Empty(); - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ProviderId { get; } - [Key(2)] public string ServiceId { get; } - [Key(3)] public string VerseId { get; } - [Key(4)] public string Title { get; } - [Key(5)] public string Kind { get; } - [Key(6)] public string CultMeshAddress { get; } - [Key(7)] public string UpdatedAtUtc { get; } - [Key(8)] public EveProviderFreshness Freshness { get; } - [Key(9)] public IReadOnlyList Schemas { get; } - [Key(10)] public IReadOnlyList Witnesses { get; } - [Key(11)] public IReadOnlyList Surfaces { get; } - [Key(12)] public IReadOnlyList Commands { get; } - [Key(13)] public IReadOnlyList AuthorizedBodyProducerIds { get; } - } - - /// - /// Reads canonical provider advertisements and the original Aetheria-expanded v1 layout. - /// - public sealed class EveProviderAdvertisementCompatibilityFormatter : - IMessagePackFormatter - { - private const int CurrentFieldCount = 14; - private const int LegacyFieldCount = 16; - - public void Serialize( - ref MessagePackWriter writer, - EveProviderAdvertisementDocument? value, - MessagePackSerializerOptions options) - { - if (value == null) { writer.WriteNil(); return; } - writer.WriteArrayHeader(CurrentFieldCount); - writer.Write(value.Schema); - writer.Write(value.ProviderId); - writer.Write(value.ServiceId); - writer.Write(value.VerseId); - writer.Write(value.Title); - writer.Write(value.Kind); - writer.Write(value.CultMeshAddress); - writer.Write(value.UpdatedAtUtc); - Formatter(options).Serialize(ref writer, value.Freshness, options); - Formatter>(options).Serialize(ref writer, value.Schemas, options); - Formatter>(options).Serialize(ref writer, value.Witnesses, options); - Formatter>(options).Serialize(ref writer, value.Surfaces, options); - Formatter>(options).Serialize(ref writer, value.Commands, options); - Formatter>(options).Serialize(ref writer, value.AuthorizedBodyProducerIds, options); - } - - public EveProviderAdvertisementDocument? Deserialize( - ref MessagePackReader reader, - MessagePackSerializerOptions options) - { - if (reader.TryReadNil()) return null; - if (reader.NextMessagePackType != MessagePackType.Array) - throw new MessagePackSerializationException("Eve provider advertisement must be an array."); - - options.Security.DepthStep(ref reader); - try - { - var fields = reader.ReadArrayHeader(); - if (fields == LegacyFieldCount) - return ReadLegacy(ref reader, options); - - var schema = ReadString(ref reader, fields, 0); - var providerId = ReadString(ref reader, fields, 1); - var serviceId = ReadString(ref reader, fields, 2); - var verseId = ReadString(ref reader, fields, 3); - var title = ReadString(ref reader, fields, 4); - var kind = ReadString(ref reader, fields, 5); - var address = ReadString(ref reader, fields, 6); - var updatedAtUtc = ReadString(ref reader, fields, 7); - var freshness = fields > 8 ? Formatter(options).Deserialize(ref reader, options) : null; - var schemas = fields > 9 ? Formatter>(options).Deserialize(ref reader, options) : null; - var witnesses = fields > 10 ? Formatter>(options).Deserialize(ref reader, options) : null; - var surfaces = fields > 11 ? Formatter>(options).Deserialize(ref reader, options) : null; - var commands = fields > 12 ? Formatter>(options).Deserialize(ref reader, options) : null; - var authorizedBodyProducerIds = fields > 13 ? Formatter>(options).Deserialize(ref reader, options) : null; - for (var index = CurrentFieldCount; index < fields; index++) reader.Skip(); - return new EveProviderAdvertisementDocument( - schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc, - freshness ?? new EveProviderFreshness("unknown", "", 0), - schemas ?? Array.Empty(), - witnesses ?? Array.Empty(), - surfaces ?? Array.Empty(), - commands ?? Array.Empty(), - authorizedBodyProducerIds ?? Array.Empty()); - } - finally { reader.Depth--; } - } - - private static EveProviderAdvertisementDocument ReadLegacy( - ref MessagePackReader reader, - MessagePackSerializerOptions options) - { - var schema = ReadString(ref reader); - var providerId = ReadString(ref reader); - var serviceId = ReadString(ref reader); - var verseId = ReadString(ref reader); - reader.Skip(); // RootVerse remains provider-owned settings, not portable advertisement state. - reader.Skip(); // CanonicalService - reader.Skip(); // LocatedService - var address = ReadString(ref reader); - var title = ReadString(ref reader); - var kind = ReadString(ref reader); - var updatedAtUtc = ReadString(ref reader); - var freshness = Formatter(options).Deserialize(ref reader, options) - ?? new EveProviderFreshness("unknown", "", 0); - var schemas = Formatter>(options).Deserialize(ref reader, options) - ?? Array.Empty(); - var witnesses = Formatter>(options).Deserialize(ref reader, options) - ?? Array.Empty(); - var surfaces = ReadLegacySurfaces(ref reader); - var commands = ReadLegacyCommands(ref reader); - return new EveProviderAdvertisementDocument( - schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc, - freshness, schemas, witnesses, surfaces, commands); - } - - private static EveAdvertisedSurface[] ReadLegacySurfaces(ref MessagePackReader reader) - { - var count = reader.ReadArrayHeader(); - var values = new EveAdvertisedSurface[count]; - for (var index = 0; index < count; index++) - { - var fields = reader.ReadArrayHeader(); - var schema = ReadString(ref reader, fields, 0); - var surfaceId = ReadString(ref reader, fields, 1); - var recordRef = ReadString(ref reader, fields, 2); - var transport = ReadString(ref reader, fields, 3); - var status = ReadString(ref reader, fields, 4); - for (var field = 5; field < fields; field++) reader.Skip(); - values[index] = new EveAdvertisedSurface( - surfaceId, schema, recordRef, transport, status, "", null); - } - return values; - } - - private static EveAdvertisedCommand[] ReadLegacyCommands(ref MessagePackReader reader) - { - var count = reader.ReadArrayHeader(); - var values = new EveAdvertisedCommand[count]; - for (var index = 0; index < count; index++) - { - var fields = reader.ReadArrayHeader(); - var command = ReadString(ref reader, fields, 0); - var transport = ReadString(ref reader, fields, 1); - var summary = ReadString(ref reader, fields, 2); - for (var field = 3; field < fields; field++) reader.Skip(); - values[index] = new EveAdvertisedCommand(command, "", transport, summary); - } - return values; - } - - private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => - options.Resolver.GetFormatter() - ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); - - private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; - private static string ReadString(ref MessagePackReader reader, int fields, int index) => - index < fields ? ReadString(ref reader) : ""; - } - - - [MessagePackObject] - public sealed class EveProviderFreshness - { - [SerializationConstructor] - public EveProviderFreshness(string state, string lastSeenAtUtc, long maxAgeMs) - { - State = state ?? ""; - LastSeenAtUtc = lastSeenAtUtc ?? ""; - MaxAgeMs = maxAgeMs; - } - - [Key(0)] public string State { get; } - [Key(1)] public string LastSeenAtUtc { get; } - [Key(2)] public long MaxAgeMs { get; } - } - - [MessagePackObject] - public sealed class EveProviderWitness - { - [SerializationConstructor] - public EveProviderWitness(string kind, string reference, string summary) - { - Kind = kind ?? ""; - Reference = reference ?? ""; - Summary = summary ?? ""; - } - - [Key(0)] public string Kind { get; } - [Key(1)] public string Reference { get; } - [Key(2)] public string Summary { get; } - } - - [MessagePackObject] - public sealed class EveAdvertisedSurface - { - [SerializationConstructor] - public EveAdvertisedSurface( - string surfaceId, - string schema, - string recordRef, - string transport, - string status, - string surfaceKind, - EveWorldInteractionAdvertisement? worldInteraction = null) - { - SurfaceId = surfaceId ?? ""; - Schema = schema ?? ""; - RecordRef = recordRef ?? ""; - Transport = transport ?? ""; - Status = status ?? ""; - SurfaceKind = surfaceKind ?? ""; - WorldInteraction = worldInteraction; - } - - [Key(0)] public string SurfaceId { get; } - [Key(1)] public string Schema { get; } - [Key(2)] public string RecordRef { get; } - [Key(3)] public string Transport { get; } - [Key(4)] public string Status { get; } - [Key(5)] public string SurfaceKind { get; } - [Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; } - } - - [MessagePackObject] - public sealed class EveWorldInteractionAdvertisement - { - [SerializationConstructor] - public EveWorldInteractionAdvertisement( - string projectionKind, - IReadOnlyList stateSchemas, - string commandBoundary, - string commandRecordRef, - string receiptSchema, - string receiptRecordRef, - string assetManifestRecordRef, - IReadOnlyList loweringTargets, - string ownership) - { - ProjectionKind = projectionKind ?? ""; - StateSchemas = stateSchemas ?? Array.Empty(); - CommandBoundary = commandBoundary ?? ""; - CommandRecordRef = commandRecordRef ?? ""; - ReceiptSchema = receiptSchema ?? ""; - ReceiptRecordRef = receiptRecordRef ?? ""; - AssetManifestRecordRef = assetManifestRecordRef ?? ""; - LoweringTargets = loweringTargets ?? Array.Empty(); - Ownership = ownership ?? ""; - } - - [Key(0)] public string ProjectionKind { get; } - [Key(1)] public IReadOnlyList StateSchemas { get; } - [Key(2)] public string CommandBoundary { get; } - [Key(3)] public string CommandRecordRef { get; } - [Key(4)] public string ReceiptSchema { get; } - [Key(5)] public string ReceiptRecordRef { get; } - [Key(6)] public string AssetManifestRecordRef { get; } - [Key(7)] public IReadOnlyList LoweringTargets { get; } - [Key(8)] public string Ownership { get; } - } - - [MessagePackObject] - public sealed class EveAdvertisedCommand - { - [SerializationConstructor] - public EveAdvertisedCommand(string command, string surfaceId, string transport, string summary) - { - Command = command ?? ""; - SurfaceId = surfaceId ?? ""; - Transport = transport ?? ""; - Summary = summary ?? ""; - } - - [Key(0)] public string Command { get; } - [Key(1)] public string SurfaceId { get; } - [Key(2)] public string Transport { get; } - [Key(3)] public string Summary { get; } - } - - [CultDocument("gamecult.eve.command_receipt", SchemaId)] - [MessagePackObject] - public sealed class EveCommandReceiptDocument - { - public const string SchemaId = "gamecult.eve.command_receipt.v1"; - - public EveCommandReceiptDocument( - string receiptId, - string commandId, - string command, - string state, - string ownerRepo, - string authority, - string providerId, - string surfaceId, - string message, - string issuedAtUtc, - long sourceVersion) - : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion) - { - } - - [SerializationConstructor] - public EveCommandReceiptDocument( - string schema, - string receiptId, - string commandId, - string command, - string state, - string ownerRepo, - string authority, - string providerId, - string surfaceId, - string message, - string issuedAtUtc, - long sourceVersion) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ReceiptId = receiptId ?? ""; - CommandId = commandId ?? ""; - Command = command ?? ""; - State = state ?? ""; - OwnerRepo = ownerRepo ?? ""; - Authority = authority ?? ""; - ProviderId = providerId ?? ""; - SurfaceId = surfaceId ?? ""; - Message = message ?? ""; - IssuedAtUtc = issuedAtUtc ?? ""; - SourceVersion = sourceVersion; - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ReceiptId { get; } - [Key(2)] public string CommandId { get; } - [Key(3)] public string Command { get; } - [Key(4)] public string State { get; } - [Key(5)] public string OwnerRepo { get; } - [Key(6)] public string Authority { get; } - [Key(7)] public string ProviderId { get; } - [Key(8)] public string SurfaceId { get; } - [Key(9)] public string Message { get; } - [Key(10)] public string IssuedAtUtc { get; } - [Key(11)] public long SourceVersion { get; } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta deleted file mode 100644 index f29c875..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f7ea0cd07c4d5da4099b5acf8d44ca2e \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs deleted file mode 100644 index 2d88eb5..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs +++ /dev/null @@ -1,874 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - /// - /// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors. - /// - public sealed class EveSurfaceBuilder - { - private readonly string _surfaceId; - private readonly List _children = new(); - private readonly List _styles = new(); - private readonly List _commands = new(); - private EveSurfaceComponent? _root; - private long _version = 1; - private string _providerId = "gamecult"; - private string _providerKind = "cultui.surface"; - private string _title = ""; - private string _updatedAtUtc = ""; - - public EveSurfaceBuilder(string surfaceId) - { - _surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId)); - } - - public EveSurfaceBuilder Provider(string providerId, string providerKind) - { - _providerId = providerId ?? ""; - _providerKind = providerKind ?? ""; - return this; - } - - public EveSurfaceBuilder Title(string title) - { - _title = title ?? ""; - _children.Add(Text($"{_surfaceId}.title", _title, "text.title")); - return this; - } - - public EveSurfaceBuilder TitleSubtitle(string title, string subtitle) - { - _title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}"; - _children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title")); - _children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle")); - return this; - } - - public EveSurfaceBuilder Text(string text, string? id = null) - { - _children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text")); - return this; - } - - public EveSurfaceBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _commands.Add(new EveCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _commands.Add(new EveCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder ButtonColumn(string id, Action build) - { - return Group(id, "column", build); - } - - public EveSurfaceBuilder ButtonRow(string id, Action build) - { - return Group(id, "row", build); - } - - public EveSurfaceBuilder Form(string id, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var form = new EveSurfaceFormBuilder(id, AddCommand); - build(form); - _children.Add(form.Build()); - return this; - } - - public EveSurfaceBuilder RootColumn(string id, Action build) - { - _root = BuildContainer(id, "partition", Props(("split", "y")), build); - return this; - } - - public EveSurfaceBuilder RootRow(string id, Action build) - { - _root = BuildContainer(id, "partition", Props(("split", "x")), build); - return this; - } - - public EveSurfaceBuilder RootGrid( - string id, - int columns, - int rows, - Action build) - { - _root = BuildContainer( - id, - "grid", - Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build); - return this; - } - - public EveSurfaceBuilder Row(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", Props(("split", "x")), build)); - return this; - } - - public EveSurfaceBuilder Column(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", Props(("split", "y")), build)); - return this; - } - - public EveSurfaceBuilder Grid(string id, int columns, int rows, Action build) - { - _children.Add(BuildContainer( - id, - "grid", - Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build)); - return this; - } - - public EveSurfaceBuilder Modal(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "modal", Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceBuilder EmbeddedDocument( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - { - _children.Add(new EveSurfaceComponent( - $"{_surfaceId}.slot.{Slug(slotId)}", - "surface.slot", - new Dictionary(StringComparer.Ordinal) - { - ["slotId"] = slotId ?? "", - ["documentId"] = documentId ?? "", - ["schemaId"] = schemaId ?? "", - ["presentationKind"] = presentationKind ?? "" - }, - Array.Empty(), - Array.Empty(), - new[] - { - new EveEmbeddedDocumentSlot( - slotId ?? "", - documentId ?? "", - schemaId ?? "", - presentationKind ?? "", - routeHint) - })); - return this; - } - - public EveSurfaceBuilder Style(string name, string value) - { - _styles.Add(new EveStyleToken(name, value)); - return this; - } - - public EveSurfaceBuilder Version(long version) - { - _version = version; - return this; - } - - public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc) - { - _updatedAtUtc = updatedAtUtc ?? ""; - return this; - } - - public EveSurfaceDocument Build() - { - return new EveSurfaceDocument( - _providerId, - _providerKind, - _title, - _version, - string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc, - new EveSurfaceTree( - _surfaceId, - _root ?? new EveSurfaceComponent( - $"{_surfaceId}.root", - "surface", - EmptyProps(), - _children.ToArray()), - _styles.ToArray()), - _commands.ToArray()); - } - - private EveSurfaceBuilder Group(string id, string kind, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var group = new EveSurfaceGroupBuilder(id, kind, AddCommand); - build(group); - _children.Add(group.Build()); - return this; - } - - private EveSurfaceComponent BuildContainer( - string id, - string kind, - IReadOnlyDictionary props, - Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var container = new EveSurfaceContainerBuilder(id, kind, props, AddCommand); - build(container); - return container.Build(); - } - - private void AddCommand(EveCommandTemplate command) - { - if (command != null) - _commands.Add(command); - } - - internal static EveSurfaceComponent Text(string id, string value, string kind) - { - return new EveSurfaceComponent( - id, - kind, - new Dictionary(StringComparer.Ordinal) - { - ["value"] = value ?? "" - }, - Array.Empty()); - } - - internal static EveSurfaceComponent ButtonComponent( - string id, - string label, - CultMeshOperationBindingDescriptor operation) - { - return new EveSurfaceComponent( - id, - "control.button", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? operation.Label, - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty()); - } - - internal static Dictionary EmptyProps() - { - return new Dictionary(StringComparer.Ordinal); - } - - internal static Dictionary Props(params (string Key, string Value)[] values) - { - return (values ?? Array.Empty<(string Key, string Value)>()) - .ToDictionary(value => value.Key, value => value.Value ?? "", StringComparer.Ordinal); - } - - internal static string Slug(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return "unnamed"; - - var chars = value - .Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.') - .ToArray(); - var slug = new string(chars).Trim('.'); - while (slug.Contains("..", StringComparison.Ordinal)) - slug = slug.Replace("..", ".", StringComparison.Ordinal); - return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug; - } - - private static string RequireNonEmpty(string value, string paramName) - { - return string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Value must be non-empty.", paramName) - : value; - } - } - - public sealed class EveSurfaceContainerBuilder - { - private readonly string _id; - private readonly string _kind; - private readonly Dictionary _props; - private readonly Action _addCommand; - private readonly List _children = new(); - private readonly List _stateBindings = new(); - private readonly List _embeddedDocuments = new(); - - internal EveSurfaceContainerBuilder( - string id, - string kind, - IReadOnlyDictionary props, - Action addCommand) - { - _id = id ?? ""; - _kind = kind ?? "partition"; - _props = new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal); - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceContainerBuilder Gap(int value) => Prop("gap", value); - - public EveSurfaceContainerBuilder Padding(int value) => Prop("padding", value); - - public EveSurfaceContainerBuilder Size(string value) => Prop("size", value); - - public EveSurfaceContainerBuilder Min(string value) => Prop("min", value); - - public EveSurfaceContainerBuilder Max(string value) => Prop("max", value); - - public EveSurfaceContainerBuilder Align(string value) => Prop("align", value); - - public EveSurfaceContainerBuilder Clip(string value = "hidden") => Prop("clip", value); - - public EveSurfaceContainerBuilder Scroll(string value = "y") => Prop("scroll", value); - - public EveSurfaceContainerBuilder Region(string value) => Prop("region", value); - - public EveSurfaceContainerBuilder Bind(CultMeshStateBindingDescriptor binding) - { - if (binding != null) - _stateBindings.Add(binding); - return this; - } - - public EveSurfaceContainerBuilder Row(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build)); - return this; - } - - public EveSurfaceContainerBuilder Column(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build)); - return this; - } - - public EveSurfaceContainerBuilder Grid( - string id, - int columns, - int rows, - Action build) - { - _children.Add(BuildContainer( - id, - "grid", - EveSurfaceBuilder.Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build)); - return this; - } - - public EveSurfaceContainerBuilder Pane(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder Card(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder Modal(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder FieldRow( - string id, - string label, - Action build) - { - return Row(id, row => row - .Gap(8) - .Label($"{id}.label", label, labelPart => labelPart.Size("12rem")) - .Column($"{id}.field", field => - { - field.Size("1fr"); - build(field); - })); - } - - public EveSurfaceContainerBuilder Label( - string id, - string text, - Action? configure = null) - { - return Leaf(id, "label", EveSurfaceBuilder.Props(("value", text ?? "")), null, null, configure); - } - - public EveSurfaceContainerBuilder Text(string id, string text) - { - return Leaf(id, "text", EveSurfaceBuilder.Props(("value", text ?? ""))); - } - - public EveSurfaceContainerBuilder Title(string id, string text) - { - return Leaf(id, "text.title", EveSurfaceBuilder.Props(("value", text ?? ""))); - } - - public EveSurfaceContainerBuilder Metric( - string id, - string label, - string value, - CultMeshStateBindingDescriptor? binding = null) - { - return Leaf( - id, - "metric", - EveSurfaceBuilder.Props(("label", label ?? ""), ("value", value ?? "")), - binding); - } - - public EveSurfaceContainerBuilder Progress( - string id, - string label, - string value, - string min, - string max, - CultMeshStateBindingDescriptor? binding = null) - { - return Leaf( - id, - "progress", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("value", value ?? ""), - ("min", min ?? ""), - ("max", max ?? "")), - binding); - } - - public EveSurfaceContainerBuilder Button(string id, string label, string command) - { - return Button(id, label, CultMesh.OperationBinding(command, label)); - } - - public EveSurfaceContainerBuilder Button( - string id, - string label, - CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent(id, label, operation)); - return this; - } - - public EveSurfaceContainerBuilder PopupButton( - string id, - string label, - string title, - Action build, - string anchor = "viewport", - string placement = "center", - int offsetX = 0, - int offsetY = 0) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var popup = BuildContainer( - $"{id}.popup", - "modal", - EveSurfaceBuilder.Props( - ("title", title ?? label ?? ""), - ("anchor", anchor ?? "viewport"), - ("placement", placement ?? "center"), - ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("presentation", "popover")), - build); - _children.Add(new EveSurfaceComponent( - id ?? "", - "control.popup", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("title", title ?? label ?? ""), - ("anchor", anchor ?? "viewport"), - ("placement", placement ?? "center"), - ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture))), - new[] { popup })); - return this; - } - - public EveSurfaceContainerBuilder EnumField( - string id, - string label, - string value, - CultMeshStateBindingDescriptor optionsBinding, - CultMeshOperationBindingDescriptor setValue, - string anchor = "trigger", - string placement = "right-down") - { - return FieldRow(id, label, field => field.PopupButton( - $"{id}.dropdown", - value, - label, - popup => popup.OptionList($"{id}.options", label, optionsBinding, setValue), - anchor, - placement)); - } - - public EveSurfaceContainerBuilder OptionList( - string id, - string label, - CultMeshStateBindingDescriptor itemsBinding, - CultMeshOperationBindingDescriptor operation) - { - if (itemsBinding == null) throw new ArgumentNullException(nameof(itemsBinding)); - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(new EveSurfaceComponent( - id ?? "", - "options", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("command", operation.OperationId), - ("operationId", operation.OperationId), - ("schemaId", operation.SchemaId)), - Array.Empty(), - new[] { itemsBinding })); - return this; - } - - public EveSurfaceContainerBuilder TextInput( - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.input.text", id, label, value, operation, binding); - } - - public EveSurfaceContainerBuilder Toggle( - string id, - string label, - bool value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.toggle", id, label, value ? "true" : "false", operation, binding); - } - - public EveSurfaceContainerBuilder Slider( - string id, - string label, - string value, - string min, - string max, - string step, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control( - "control.slider", - id, - label, - value, - operation, - binding, - ("min", min ?? ""), - ("max", max ?? ""), - ("step", step ?? "")); - } - - public EveSurfaceContainerBuilder Select( - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.select", id, label, value, operation, binding); - } - - public EveSurfaceContainerBuilder EmbeddedDocument( - string id, - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - { - _children.Add(new EveSurfaceComponent( - id ?? "", - "surface.slot", - EveSurfaceBuilder.Props( - ("slotId", slotId ?? ""), - ("documentId", documentId ?? ""), - ("schemaId", schemaId ?? ""), - ("presentationKind", presentationKind ?? "")), - Array.Empty(), - Array.Empty(), - new[] - { - new EveEmbeddedDocumentSlot( - slotId ?? "", - documentId ?? "", - schemaId ?? "", - presentationKind ?? "", - routeHint) - })); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent( - _id, - _kind, - _props, - _children.ToArray(), - _stateBindings.ToArray(), - _embeddedDocuments.ToArray()); - } - - private EveSurfaceContainerBuilder Prop(string key, int value) - { - _props[key] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); - return this; - } - - private EveSurfaceContainerBuilder Prop(string key, string value) - { - _props[key] = value ?? ""; - return this; - } - - private EveSurfaceContainerBuilder Leaf( - string id, - string kind, - IReadOnlyDictionary props, - CultMeshStateBindingDescriptor? binding = null, - IReadOnlyList? children = null, - Action? configure = null) - { - var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); - configure?.Invoke(builder); - var component = new EveSurfaceComponent( - id ?? "", - kind ?? "", - new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal), - children ?? Array.Empty(), - binding == null ? Array.Empty() : new[] { binding }); - if (builder._props.Count != (props?.Count ?? 0)) - { - component = new EveSurfaceComponent( - id ?? "", - kind ?? "", - new Dictionary(builder._props, StringComparer.Ordinal), - children ?? Array.Empty(), - binding == null ? Array.Empty() : new[] { binding }); - } - _children.Add(component); - return this; - } - - private EveSurfaceContainerBuilder Control( - string kind, - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding, - params (string Key, string Value)[] extraProps) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - var props = EveSurfaceBuilder.Props( - new[] - { - ("label", label ?? ""), - ("value", value ?? ""), - ("command", operation.OperationId), - ("operationId", operation.OperationId), - ("schemaId", operation.SchemaId) - }.Concat(extraProps ?? Array.Empty<(string Key, string Value)>()).ToArray()); - return Leaf(kind: kind, id: id, props: props, binding: binding); - } - - private EveSurfaceComponent BuildContainer( - string id, - string kind, - IReadOnlyDictionary props, - Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var container = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); - build(container); - return container.Build(); - } - } - - public sealed class EveSurfaceGroupBuilder - { - private readonly string _id; - private readonly string _kind; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceGroupBuilder( - string id, - string kind, - Action addCommand) - { - _id = id ?? ""; - _kind = kind ?? "column"; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceGroupBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - } - - public sealed class EveSurfaceFormBuilder - { - private readonly string _id; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceFormBuilder(string id, Action addCommand) - { - _id = id ?? ""; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceFormBuilder Text( - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(Control( - "control.text", - label, - value, - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Toggle( - string label, - bool value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(Control( - "control.toggle", - label, - value ? "true" : "false", - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Metric( - string label, - string value, - CultMeshStateBindingDescriptor? binding = null) - { - var bindings = binding == null - ? Array.Empty() - : new[] { binding }; - _children.Add(new EveSurfaceComponent( - $"{_id}.metric.{EveSurfaceBuilder.Slug(label)}", - "metric", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "" - }, - Array.Empty(), - bindings)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - - private EveSurfaceComponent Control( - string kind, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding) - { - var bindings = binding == null - ? Array.Empty() - : new[] { binding }; - return new EveSurfaceComponent( - $"{_id}.{EveSurfaceBuilder.Slug(label)}", - kind, - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "", - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty(), - bindings); - } - } - - public static class EveSurface - { - public static EveSurfaceBuilder Create(string surfaceId) - { - return new EveSurfaceBuilder(surfaceId); - } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta deleted file mode 100644 index adc41da..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 97ac7f757692aef4db64d75d0df6fe51 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs deleted file mode 100644 index 9eed51f..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs +++ /dev/null @@ -1,538 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GameCult.Caching; -using GameCult.Mesh; -using MessagePack; -using MessagePack.Formatters; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] - [MessagePackObject] - [MessagePackFormatter(typeof(EveSurfaceDocumentCompatibilityFormatter))] - public sealed class EveSurfaceDocument - { - public const string DefaultType = "surface-state"; - public const string SchemaId = "gamecult.eve.surface.v1"; - - public EveSurfaceDocument( - string providerId, - string providerKind, - string title, - long version, - string updatedAtUtc, - EveSurfaceTree surface, - IReadOnlyList commands) - : this(DefaultType, SchemaId, providerId, providerKind, title, version, updatedAtUtc, surface, commands) - { - } - - [SerializationConstructor] - public EveSurfaceDocument( - string type, - string schema, - string providerId, - string providerKind, - string title, - long version, - string updatedAtUtc, - EveSurfaceTree surface, - IReadOnlyList commands) - { - Type = string.IsNullOrWhiteSpace(type) ? DefaultType : type; - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - ProviderKind = providerKind ?? ""; - Title = title ?? ""; - Version = version; - UpdatedAtUtc = updatedAtUtc ?? ""; - Surface = surface ?? throw new ArgumentNullException(nameof(surface)); - Commands = commands ?? Array.Empty(); - } - - [Key(0)] - public string Type { get; } - - [Key(1)] - public string Schema { get; } - - [Key(2)] - public string ProviderId { get; } - - [Key(3)] - public string ProviderKind { get; } - - [Key(4)] - public string Title { get; } - - [Key(5)] - public long Version { get; } - - [Key(6)] - public string UpdatedAtUtc { get; } - - [Key(7)] - public EveSurfaceTree Surface { get; } - - [Key(8)] - public IReadOnlyList Commands { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceTree - { - [SerializationConstructor] - public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList styles) - { - Id = id ?? ""; - Root = root ?? throw new ArgumentNullException(nameof(root)); - Styles = styles ?? Array.Empty(); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public EveSurfaceComponent Root { get; } - - [Key(2)] - public IReadOnlyList Styles { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceComponent - { - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children) - : this(id, kind, props, children, StateBindingsFromProps(props)) - { - } - - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - IReadOnlyList stateBindings) - : this(id, kind, props, children, stateBindings, Array.Empty()) - { - } - - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - IReadOnlyList stateBindings, - IReadOnlyList embeddedDocuments, - IReadOnlyDictionary? layout = null, - IReadOnlyDictionary? style = null) - : this( - id, - kind, - props, - children, - (stateBindings ?? Array.Empty()) - .Select(CultMeshStateBindingRecord.FromBinding) - .ToArray(), - embeddedDocuments, - layout, - style) - { - } - - [SerializationConstructor] - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - CultMeshStateBindingRecord[] stateBindingRecords, - IReadOnlyList embeddedDocuments, - IReadOnlyDictionary? layout = null, - IReadOnlyDictionary? style = null) - { - Id = id ?? ""; - Kind = kind ?? ""; - Props = props ?? new Dictionary(StringComparer.Ordinal); - Children = children ?? Array.Empty(); - StateBindingRecords = stateBindingRecords ?? Array.Empty(); - EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); - Layout = layout ?? new Dictionary(StringComparer.Ordinal); - Style = style ?? new Dictionary(StringComparer.Ordinal); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public string Kind { get; } - - [Key(2)] - public IReadOnlyDictionary Props { get; } - - [Key(3)] - public IReadOnlyList Children { get; } - - [Key(4)] - public CultMeshStateBindingRecord[] StateBindingRecords { get; } - - [IgnoreMember] - public IReadOnlyList StateBindings => - StateBindingRecords.Select(record => record.ToBinding()).ToArray(); - - [Key(5)] - public IReadOnlyList EmbeddedDocuments { get; } - - [Key(6)] - public IReadOnlyDictionary Layout { get; } - - [Key(7)] - public IReadOnlyDictionary Style { get; } - - public string GetProp(string key, string fallback = "") - { - return Props.TryGetValue(key, out var value) ? value : fallback; - } - - private static IReadOnlyList StateBindingsFromProps( - IReadOnlyDictionary? props) - { - if (props == null || props.Count == 0) - return Array.Empty(); - - var bindings = new List(); - foreach (var prop in props) - { - if (string.IsNullOrWhiteSpace(prop.Value) || - !prop.Key.EndsWith("PointerId", StringComparison.Ordinal)) - { - continue; - } - - var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length); - if (targetProp.Length == 0) - targetProp = "value"; - bindings.Add(new CultMeshStateBindingDescriptor(targetProp, prop.Value)); - } - - return bindings; - } - } - - [MessagePackObject] - public sealed class EveEmbeddedDocumentSlot - { - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - : this(slotId, documentId, schemaId, presentationKind, CultMeshRouteRecord.FromRoute(routeHint)) - { - } - - [SerializationConstructor] - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteRecord route) - { - SlotId = slotId ?? ""; - DocumentId = documentId ?? ""; - SchemaId = schemaId ?? ""; - PresentationKind = presentationKind ?? ""; - Route = route ?? CultMeshRouteRecord.FromRoute(CultMeshRouteHint.Automatic); - } - - [Key(0)] - public string SlotId { get; } - - [Key(1)] - public string DocumentId { get; } - - [Key(2)] - public string SchemaId { get; } - - [Key(3)] - public string PresentationKind { get; } - - [Key(4)] - public CultMeshRouteRecord Route { get; } - - [IgnoreMember] - public CultMeshRouteHint RouteHint => Route.ToRoute(); - } - - [MessagePackObject] - public sealed class EveStyleToken - { - [SerializationConstructor] - public EveStyleToken(string name, string value) - { - Name = name ?? ""; - Value = value ?? ""; - } - - [Key(0)] - public string Name { get; } - - [Key(1)] - public string Value { get; } - } - - [MessagePackObject] - [MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))] - public sealed class EveCommandTemplate - { - public EveCommandTemplate(CultMeshOperationBindingDescriptor operation) - : this(CultMeshOperationBindingRecord.FromBinding(operation)) - { - } - - [SerializationConstructor] - public EveCommandTemplate(CultMeshOperationBindingRecord operationRecord) - { - OperationRecord = operationRecord ?? throw new ArgumentNullException(nameof(operationRecord)); - } - - [Key(0)] - public CultMeshOperationBindingRecord OperationRecord { get; } - - [IgnoreMember] - public CultMeshOperationBindingDescriptor Operation => OperationRecord.ToBinding(); - - [IgnoreMember] - public string Command => Operation.OperationId; - - [IgnoreMember] - public string Label => Operation.Label; - - [IgnoreMember] - public string Transport => Operation.RouteHint.Description ?? ""; - } - - [CultDocument("gamecult.eve.command_invocation", SchemaId)] - [MessagePackObject] - public sealed class EveSurfaceCommandRequest - { - public const string SchemaId = "gamecult.eve.command_invocation.v1"; - - public EveSurfaceCommandRequest( - string providerId, - string surfaceId, - CultMeshOperationInvocationDescriptor operation, - CultMeshOperationPayload payload, - DateTimeOffset issuedAt, - string clientId, - string commandBoundary = "", - string receiptSchema = "") - : this( - SchemaId, - providerId, - surfaceId, - CultMeshOperationInvocationRecord.FromInvocation(operation), - payload?.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal) - ?? new Dictionary(StringComparer.Ordinal), - issuedAt, - clientId, - commandBoundary, - receiptSchema) - { - } - - [SerializationConstructor] - public EveSurfaceCommandRequest( - string schema, - string providerId, - string surfaceId, - CultMeshOperationInvocationRecord operation, - Dictionary payload, - DateTimeOffset issuedAt, - string clientId, - string commandBoundary, - string receiptSchema) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId; - SurfaceId = surfaceId; - OperationRecord = operation ?? throw new ArgumentNullException(nameof(operation)); - PayloadFields = payload == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(payload, StringComparer.Ordinal); - IssuedAt = issuedAt; - ClientId = clientId; - CommandBoundary = commandBoundary ?? ""; - ReceiptSchema = receiptSchema ?? ""; - } - - [Key(0)] - public string Schema { get; } - - [Key(1)] - public string ProviderId { get; } - - [Key(2)] - public string SurfaceId { get; } - - [Key(3)] - public CultMeshOperationInvocationRecord OperationRecord { get; } - - [IgnoreMember] - public CultMeshOperationInvocationDescriptor Operation => OperationRecord.ToInvocation(); - - [IgnoreMember] - public string Command => Operation.OperationId; - - [Key(4)] - public Dictionary PayloadFields { get; } - - [IgnoreMember] - public CultMeshOperationPayload Payload => new CultMeshOperationPayload(PayloadFields); - - [Key(5)] - public DateTimeOffset IssuedAt { get; } - - [Key(6)] - public string ClientId { get; } - - [Key(7)] - public string CommandBoundary { get; } - - [Key(8)] - public string ReceiptSchema { get; } - - [IgnoreMember] - public string CommandId => Operation.IdempotencyKey ?? ""; - } - - /// - /// Reads the current Eve surface envelope and the original seven-field v1 envelope. - /// - public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter - { - private const int LegacyFieldCount = 7; - private const int CurrentFieldCount = 9; - - public void Serialize(ref MessagePackWriter writer, EveSurfaceDocument? value, MessagePackSerializerOptions options) - { - if (value == null) { writer.WriteNil(); return; } - writer.WriteArrayHeader(CurrentFieldCount); - writer.Write(value.Type); - writer.Write(value.Schema); - writer.Write(value.ProviderId); - writer.Write(value.ProviderKind); - writer.Write(value.Title); - writer.Write(value.Version); - writer.Write(value.UpdatedAtUtc); - Formatter(options).Serialize(ref writer, value.Surface, options); - Formatter>(options).Serialize(ref writer, value.Commands, options); - } - - public EveSurfaceDocument? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) - { - if (reader.TryReadNil()) return null; - if (reader.NextMessagePackType != MessagePackType.Array) - throw new MessagePackSerializationException("Eve surface document must be an array."); - - options.Security.DepthStep(ref reader); - try - { - var fields = reader.ReadArrayHeader(); - if (fields == LegacyFieldCount) - { - return new EveSurfaceDocument( - ReadString(ref reader), ReadString(ref reader), ReadString(ref reader), - reader.ReadInt64(), ReadString(ref reader), - Formatter(options).Deserialize(ref reader, options)!, - Formatter>(options).Deserialize(ref reader, options) - ?? Array.Empty()); - } - - var type = ReadString(ref reader, fields, 0); - var schema = ReadString(ref reader, fields, 1); - var providerId = ReadString(ref reader, fields, 2); - var providerKind = ReadString(ref reader, fields, 3); - var title = ReadString(ref reader, fields, 4); - var version = fields > 5 ? reader.ReadInt64() : 0; - var updatedAtUtc = ReadString(ref reader, fields, 6); - var surface = fields > 7 ? Formatter(options).Deserialize(ref reader, options) : null; - var commands = fields > 8 ? Formatter>(options).Deserialize(ref reader, options) : null; - for (var index = CurrentFieldCount; index < fields; index++) reader.Skip(); - return new EveSurfaceDocument( - type, schema, providerId, providerKind, title, version, updatedAtUtc, - surface ?? throw new MessagePackSerializationException("Eve surface tree is required."), - commands ?? Array.Empty()); - } - finally { reader.Depth--; } - } - - private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => - options.Resolver.GetFormatter() - ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); - - private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; - private static string ReadString(ref MessagePackReader reader, int fields, int index) => - index < fields ? ReadString(ref reader) : ""; - } - - /// - /// Reads both direct legacy command fields and the current wrapped binding record. - /// - public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter - { - public void Serialize(ref MessagePackWriter writer, EveCommandTemplate? value, MessagePackSerializerOptions options) - { - if (value == null) { writer.WriteNil(); return; } - writer.WriteArrayHeader(1); - BindingFormatter(options).Serialize(ref writer, value.OperationRecord, options); - } - - public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) - { - if (reader.TryReadNil()) return null; - if (reader.NextMessagePackType != MessagePackType.Array) - throw new MessagePackSerializationException("Eve command template must be an array."); - - options.Security.DepthStep(ref reader); - try - { - var fields = reader.ReadArrayHeader(); - if (fields == 1 && reader.NextMessagePackType == MessagePackType.Array) - { - return new EveCommandTemplate( - BindingFormatter(options).Deserialize(ref reader, options) - ?? new CultMeshOperationBindingRecord()); - } - - var operationId = ReadString(ref reader, fields, 0); - var label = ReadString(ref reader, fields, 1); - var schemaId = ReadString(ref reader, fields, 2); - var routeKind = ReadString(ref reader, fields, 3); - var routeDescription = ReadString(ref reader, fields, 4); - for (var index = 5; index < fields; index++) reader.Skip(); - return new EveCommandTemplate(new CultMeshOperationBindingRecord( - operationId, label, schemaId, routeKind, routeDescription)); - } - finally { reader.Depth--; } - } - - private static IMessagePackFormatter BindingFormatter( - MessagePackSerializerOptions options) => - options.Resolver.GetFormatter() - ?? throw new MessagePackSerializationException("No CultMesh operation binding formatter is registered."); - - private static string ReadString(ref MessagePackReader reader, int fields, int index) => - index < fields ? reader.ReadString() ?? "" : ""; - } - -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta deleted file mode 100644 index ebd5f02..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6516c42104b235f42b15efeb4639bb73 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef deleted file mode 100644 index 7f19662..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "GameCult.Eve.Surface", - "rootNamespace": "GameCult.Eve.Surface", - "references": [ - "GameCult.Mesh" - ], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": true, - "precompiledReferences": [ - "GameCult.Caching.dll", - "GameCult.Mesh.dll", - "MessagePack.Annotations.dll", - "MessagePack.dll" - ], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": true -} diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta deleted file mode 100644 index d8f543e..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a9004278c14816345941af2f2ce673ca -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json deleted file mode 100644 index 36fd3b6..0000000 --- a/packages/org.gamecult.eve.surface/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "org.gamecult.eve.surface", - "displayName": "Eve Surface Contract", - "version": "0.2.4", - "unity": "2021.3", - "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", - "dependencies": { - "org.gamecult.cultlib": "1.0.8" - }, - "files": [ - "Runtime", - "Runtime.meta", - "README.md", - "README.md.meta", - "Directory.Build.props", - "Directory.Build.props.meta", - "GameCult.Eve.Surface.csproj", - "GameCult.Eve.Surface.csproj.meta", - "package.json.meta" - ], - "keywords": [ - "Eve", - "CultUI", - "CultMesh", - "Surface" - ], - "license": "MPL-2.0", - "category": "Scripting" -} diff --git a/packages/org.gamecult.eve.surface/package.json.meta b/packages/org.gamecult.eve.surface/package.json.meta deleted file mode 100644 index 8e89223..0000000 --- a/packages/org.gamecult.eve.surface/package.json.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 49f1738bd94075e489c82190cf660b1d -PackageManifestImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json index 4e07fed..244999a 100644 --- a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -160,14 +160,14 @@ "requiredPackageDependencies": [ { "packageName": "org.gamecult.eve.surface", - "version": "0.2.4", + "version": "0.3.0", "packageManager": "upm", - "ownerRepo": "EveUnity", - "purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents." + "ownerRepo": "Eve", + "purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents." }, { "packageName": "org.gamecult.eve.unity-uitoolkit", - "version": "0.1.2", + "version": "0.1.3", "packageManager": "upm", "ownerRepo": "EveUnity", "purpose": "Generic UI Toolkit lowering for provider-owned Eve panes, metrics, progress, text, and controls." diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 980c07b..e85eac9 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -7,8 +7,8 @@ "dependencies": { "org.gamecult.cultlib": "1.0.43", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.4", - "org.gamecult.eve.unity-uitoolkit": "0.1.2", + "org.gamecult.eve.surface": "0.3.0", + "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" diff --git a/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json index 7d59ddf..de1b373 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json @@ -83,10 +83,10 @@ "requiredPackageDependencies": [ { "packageName": "org.gamecult.eve.surface", - "version": "0.1.0", + "version": "0.3.0", "packageManager": "upm", - "ownerRepo": "EveUnity", - "purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents." + "ownerRepo": "Eve", + "purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents." } ] }, diff --git a/packages/org.gamecult.eve.unity-uitoolkit/package.json b/packages/org.gamecult.eve.unity-uitoolkit/package.json index ddfcd4d..e28b919 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/package.json +++ b/packages/org.gamecult.eve.unity-uitoolkit/package.json @@ -1,11 +1,11 @@ { "name": "org.gamecult.eve.unity-uitoolkit", "displayName": "Eve UI Toolkit Lowering", - "version": "0.1.2", + "version": "0.1.3", "unity": "2021.3", "description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.", "dependencies": { - "org.gamecult.eve.surface": "0.2.4" + "org.gamecult.eve.surface": "0.3.0" }, "keywords": [ "Eve", diff --git a/scripts/run-package-tests.ps1 b/scripts/run-package-tests.ps1 index b7f4b36..2c9b78f 100644 --- a/scripts/run-package-tests.ps1 +++ b/scripts/run-package-tests.ps1 @@ -10,6 +10,16 @@ $repoRoot = Split-Path -Parent $PSScriptRoot $projectPath = if ([IO.Path]::IsPathRooted($ProjectRoot)) { $ProjectRoot } else { Join-Path $repoRoot $ProjectRoot } $output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot } $cultLibBuilder = Join-Path $CultLibRoot "scripts\build-unity-package.ps1" +$obsoleteSurfacePackage = Join-Path $repoRoot "packages\org.gamecult.eve.surface" +$testManifestPath = Join-Path $projectPath "Packages\manifest.json" +if (Test-Path -LiteralPath $obsoleteSurfacePackage) { + throw "EveUnity must consume the Eve-owned surface package; duplicate source exists at $obsoleteSurfacePackage" +} +$testManifest = Get-Content -LiteralPath $testManifestPath -Raw | ConvertFrom-Json +$surfaceDependency = [string]$testManifest.dependencies."org.gamecult.eve.surface" +if ($surfaceDependency -notmatch '(?i)Eve/packages/org\.gamecult\.eve\.surface$') { + throw "EveUnity test consumer must resolve org.gamecult.eve.surface from Eve; found '$surfaceDependency'." +} foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) { if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" } } diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore deleted file mode 100644 index 296b87c..0000000 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!GameCult.Eve.Surface.Serialization.Tests.csproj diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs deleted file mode 100644 index a756e74..0000000 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System.Buffers; -using GameCult.Eve.Surface; -using GameCult.Mesh; -using MessagePack; -using NUnit.Framework; - -namespace GameCult.Eve.Surface.Tests; - -[TestFixture] -public sealed class EveSurfaceSerializationCompatibilityTests -{ - [Test] - public void EntitySoaV2SerializesLogicalLayoutWithoutTransportFields() - { - var document = new EveEntitySoaViewDocument - { - ProviderId = "provider", - ViewId = "entities", - BodySchemaId = "entity.slab.v1", - LayoutVersion = 2, - ProducerEpoch = 3, - Sequence = 4, - Capacity = 8, - FrameId = 91, - Buffers = new[] { new EveEntitySoaBuffer { BufferId = "entity-hot", ByteLength = 64 } }, - Identities = new[] - { - new EveEntityIdentity - { - Index = 0, - EntityId = "player", - EntityKind = "ship", - Label = "Vanguard", - Faction = "alliance", - Selectable = true, - Controllable = true, - AssetRef = "cultmesh://assets/player" - } - } - }; - - var bytes = MessagePackSerializer.Serialize(document, Options); - var reader = new MessagePackReader(bytes); - var restored = MessagePackSerializer.Deserialize(bytes, Options); - - Assert.That(reader.ReadArrayHeader(), Is.EqualTo(15)); - Assert.That(restored.Schema, Is.EqualTo(EveEntitySoaViewDocument.SchemaId)); - Assert.That(restored.Buffers[0].BufferId, Is.EqualTo("entity-hot")); - Assert.That(restored.FrameId, Is.EqualTo(91)); - Assert.That(restored.Identities[0].Label, Is.EqualTo("Vanguard")); - Assert.That(restored.Identities[0].Faction, Is.EqualTo("alliance")); - Assert.That(restored.Identities[0].Selectable, Is.True); - Assert.That(restored.Identities[0].Controllable, Is.True); - Assert.That(restored.Identities[0].AssetRef, Is.EqualTo("cultmesh://assets/player")); - } - - [Test] - public void EntitySoaContractsExposeNoTransportAuthorityFields() - { - var forbidden = new[] { "Backend", "Location", "CapabilityToken", "Synchronization", "SynchronizationMode", "Descriptor" }; - var exposed = new[] { typeof(EveEntitySoaViewDocument), typeof(EveEntitySoaBuffer), typeof(EveEntitySoaColumn) } - .SelectMany(type => type.GetProperties()) - .Select(property => property.Name); - - Assert.That(exposed, Has.None.Matches(name => forbidden.Contains(name, StringComparer.Ordinal))); - } - - private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard; - - [Test] - public void LegacySevenFieldSurfaceDocumentDeserializes() - { - var bytes = WriteLegacySurfaceDocument(); - - var document = MessagePackSerializer.Deserialize(bytes, Options); - - Assert.Multiple(() => - { - Assert.That(document.Type, Is.EqualTo(EveSurfaceDocument.DefaultType)); - Assert.That(document.Schema, Is.EqualTo(EveSurfaceDocument.SchemaId)); - Assert.That(document.ProviderId, Is.EqualTo("legacy-provider")); - Assert.That(document.ProviderKind, Is.EqualTo("legacy-kind")); - Assert.That(document.Title, Is.EqualTo("Legacy surface")); - Assert.That(document.Version, Is.EqualTo(17)); - Assert.That(document.UpdatedAtUtc, Is.EqualTo("2026-07-13T12:00:00Z")); - Assert.That(document.Surface.Id, Is.EqualTo("legacy-surface")); - Assert.That(document.Commands, Has.Count.EqualTo(1)); - Assert.That(document.Commands[0].Command, Is.EqualTo("legacy.execute")); - }); - } - - [Test] - public void LegacyDirectFiveStringCommandTemplateDeserializes() - { - var bytes = WriteLegacyCommandTemplate(); - - var command = MessagePackSerializer.Deserialize(bytes, Options); - - Assert.Multiple(() => - { - Assert.That(command.Command, Is.EqualTo("legacy.execute")); - Assert.That(command.Label, Is.EqualTo("Execute legacy command")); - Assert.That(command.Operation.SchemaId, Is.EqualTo("gamecult.legacy.command.v1")); - Assert.That(command.OperationRecord.RouteKind, Is.EqualTo("cultmesh")); - Assert.That(command.OperationRecord.RouteDescription, Is.EqualTo("legacy.commands")); - Assert.That(command.Transport, Is.EqualTo("legacy.commands")); - }); - } - - [Test] - public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding() - { - var document = CreateCurrentDocument(); - - var bytes = MessagePackSerializer.Serialize(document, Options); - var reader = new MessagePackReader(bytes); - - Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9)); - for (var field = 0; field < 8; field++) - reader.Skip(); - Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "commands collection"); - Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "command template wrapper"); - Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Array), "structured operation binding"); - - var roundTrip = MessagePackSerializer.Deserialize(bytes, Options); - Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute")); - } - - [Test] - public void LegacyExpandedProviderAdvertisementDeserializesAsCanonicalContract() - { - var document = MessagePackSerializer.Deserialize( - WriteLegacyProviderAdvertisement(), - Options); - - Assert.Multiple(() => - { - Assert.That(document.ProviderId, Is.EqualTo("aetheria")); - Assert.That(document.ServiceId, Is.EqualTo("aetheria-daemon")); - Assert.That(document.VerseId, Is.EqualTo("aetheria.local")); - Assert.That(document.Title, Is.EqualTo("Aetheria")); - Assert.That(document.CultMeshAddress, Is.EqualTo("cultmesh://aetheria.local/aetheria")); - Assert.That(document.Surfaces[0].SurfaceId, Is.EqualTo("aetheria.pilot")); - Assert.That(document.Surfaces[0].Schema, Is.EqualTo(EveSurfaceDocument.SchemaId)); - Assert.That(document.Commands[0].Command, Is.EqualTo("aetheria.pilot.move")); - Assert.That(document.Commands[0].SurfaceId, Is.Empty); - }); - } - - [Test] - public void CurrentProviderAdvertisementSerializationWritesCanonicalThirteenFields() - { - var document = new EveProviderAdvertisementDocument( - "provider", "service", "verse", "Title", "game.runtime", "cultmesh://verse/provider", - "2026-07-13T14:00:00Z", new EveProviderFreshness("fresh", "2026-07-13T14:00:00Z", 15000), - new[] { EveSurfaceDocument.SchemaId }, Array.Empty(), - Array.Empty(), Array.Empty()); - - var bytes = MessagePackSerializer.Serialize(document, Options); - var reader = new MessagePackReader(bytes); - - Assert.That(reader.ReadArrayHeader(), Is.EqualTo(13)); - Assert.That(MessagePackSerializer.Deserialize(bytes, Options).Title, - Is.EqualTo("Title")); - } - - private static EveSurfaceDocument CreateCurrentDocument() => new( - "current-provider", - "current-kind", - "Current surface", - 23, - "2026-07-13T13:00:00Z", - CreateSurfaceTree("current-surface"), - new[] - { - new EveCommandTemplate(new CultMeshOperationBindingRecord( - "current.execute", - "Execute current command", - "gamecult.current.command.v1", - "cultmesh", - "current.commands")) - }); - - private static EveSurfaceTree CreateSurfaceTree(string id) => new( - id, - new EveSurfaceComponent( - "root", - "column", - new Dictionary(), - Array.Empty()), - Array.Empty()); - - private static byte[] WriteLegacySurfaceDocument() - { - var buffer = new ArrayBufferWriter(); - var writer = new MessagePackWriter(buffer); - writer.WriteArrayHeader(7); - writer.Write("legacy-provider"); - writer.Write("legacy-kind"); - writer.Write("Legacy surface"); - writer.Write(17L); - writer.Write("2026-07-13T12:00:00Z"); - writer.WriteRaw(MessagePackSerializer.Serialize(CreateSurfaceTree("legacy-surface"), Options)); - writer.WriteArrayHeader(1); - writer.WriteRaw(WriteLegacyCommandTemplate()); - writer.Flush(); - return buffer.WrittenSpan.ToArray(); - } - - private static byte[] WriteLegacyCommandTemplate() - { - var buffer = new ArrayBufferWriter(); - var writer = new MessagePackWriter(buffer); - writer.WriteArrayHeader(5); - writer.Write("legacy.execute"); - writer.Write("Execute legacy command"); - writer.Write("gamecult.legacy.command.v1"); - writer.Write("cultmesh"); - writer.Write("legacy.commands"); - writer.Flush(); - return buffer.WrittenSpan.ToArray(); - } - - private static byte[] WriteLegacyProviderAdvertisement() - { - var buffer = new ArrayBufferWriter(); - var writer = new MessagePackWriter(buffer); - writer.WriteArrayHeader(16); - writer.Write(EveProviderAdvertisementDocument.SchemaId); - writer.Write("aetheria"); - writer.Write("aetheria-daemon"); - writer.Write("aetheria.local"); - writer.Write("asgard"); - writer.Write("aetheria"); - writer.Write("aetheria.local/aetheria"); - writer.Write("cultmesh://aetheria.local/aetheria"); - writer.Write("Aetheria"); - writer.Write("game.runtime"); - writer.Write("2026-07-13T14:00:00Z"); - writer.WriteArrayHeader(3); - writer.Write("fresh"); - writer.Write("2026-07-13T14:00:00Z"); - writer.Write(15000); - writer.WriteArrayHeader(1); - writer.Write(EveSurfaceDocument.SchemaId); - writer.WriteArrayHeader(0); - writer.WriteArrayHeader(1); - writer.WriteArrayHeader(5); - writer.Write(EveSurfaceDocument.SchemaId); - writer.Write("aetheria.pilot"); - writer.Write("eve:surface:aetheria.pilot"); - writer.Write("cultmesh"); - writer.Write("available"); - writer.WriteArrayHeader(1); - writer.WriteArrayHeader(3); - writer.Write("aetheria.pilot.move"); - writer.Write("cultmesh"); - writer.Write("Move the pilot ship"); - writer.Flush(); - return buffer.WrittenSpan.ToArray(); - } -} diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj deleted file mode 100644 index e3c947a..0000000 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - net10.0 - enable - enable - false - true - obj\bin\ - - - - - - - - - - - - From 329da071355ac6fafe0cdc26d8753088a10cea47 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 21:27:55 +0200 Subject: [PATCH 05/45] Require CultLib Unity 1.0.45 --- TestProject/Packages/packages-lock.json | 4 ++-- packages/org.gamecult.eve.unity-scene/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 8046efd..bbda013 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -137,7 +137,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.43" + "org.gamecult.cultlib": "1.0.45" } }, "org.gamecult.eve.unity-scene": { @@ -145,7 +145,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.43", + "org.gamecult.cultlib": "1.0.45", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.0", "org.gamecult.eve.unity-uitoolkit": "0.1.3", diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index e85eac9..72c3523 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -5,7 +5,7 @@ "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.43", + "org.gamecult.cultlib": "1.0.45", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.0", "org.gamecult.eve.unity-uitoolkit": "0.1.3", From 50cd42137a1b94fa8c578918463ddcd822d2bfc9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 21:38:30 +0200 Subject: [PATCH 06/45] Exercise Unity dropdown dispatch on a live panel --- .../Editor/EveUiToolkitSurfaceLowererTests.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs index c870112..93b83c5 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using GameCult.Eve.Surface; using GameCult.Mesh; using NUnit.Framework; +using UnityEditor; using UnityEngine.UIElements; #nullable enable @@ -31,13 +32,23 @@ namespace GameCult.Eve.UnityUIToolkit.Tests }); var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request); var field = root.Q(); + var window = EditorWindow.CreateInstance(); - Assert.That(field, Is.Not.Null); - Assert.That(field.choices, Is.EqualTo(new[] { "Local", "Hosted" })); - field.value = "Hosted"; - Assert.That(emitted, Is.Not.Null); - Assert.That(emitted!.Command, Is.EqualTo("eve.client.verse.select")); - Assert.That(emitted.PayloadFields["value"], Is.EqualTo("hosted")); + try + { + window.rootVisualElement.Add(root); + window.Show(); + Assert.That(field, Is.Not.Null); + Assert.That(field.choices, Is.EqualTo(new[] { "Local", "Hosted" })); + field.value = "Hosted"; + Assert.That(emitted, Is.Not.Null); + Assert.That(emitted!.Command, Is.EqualTo("eve.client.verse.select")); + Assert.That(emitted.PayloadFields["value"], Is.EqualTo("hosted")); + } + finally + { + window.Close(); + } } [Test] From 000917fa63d27576e2a3268538b5aa2557829aaf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 21:42:33 +0200 Subject: [PATCH 07/45] Verify released Eve Unity package graph --- ReleaseConsumerProject/Packages/manifest.json | 8 +++--- .../Packages/packages-lock.json | 26 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ReleaseConsumerProject/Packages/manifest.json b/ReleaseConsumerProject/Packages/manifest.json index 29c8b9c..07406db 100644 --- a/ReleaseConsumerProject/Packages/manifest.json +++ b/ReleaseConsumerProject/Packages/manifest.json @@ -6,11 +6,11 @@ "com.unity.modules.physics": "1.0.0", "com.unity.modules.uielements": "1.0.0", "com.unity.render-pipelines.universal": "17.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.43", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45", "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", - "org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4", - "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.102", - "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2" + "org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0", + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9", + "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9" }, "testables": [ "org.gamecult.eve.unity-scene", diff --git a/ReleaseConsumerProject/Packages/packages-lock.json b/ReleaseConsumerProject/Packages/packages-lock.json index 9ad97d9..4510f6a 100644 --- a/ReleaseConsumerProject/Packages/packages-lock.json +++ b/ReleaseConsumerProject/Packages/packages-lock.json @@ -119,11 +119,11 @@ } }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.43", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45", "depth": 0, "source": "git", "dependencies": {}, - "hash": "f67f5122ed1bd11da016e7b820ed60145ccd0299" + "hash": "03840d0430bc727f1322861a1ab46b396eaca860" }, "org.gamecult.eve.plugin-fields": { "version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", @@ -135,37 +135,37 @@ "hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0" }, "org.gamecult.eve.surface": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4", + "version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.8" + "org.gamecult.cultlib": "1.0.45" }, - "hash": "e08fa08335f99e9edddeb706912eecfad07cb281" + "hash": "fe0dddf28267decbb416325a3d0b2c62432825c2" }, "org.gamecult.eve.unity-scene": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.102", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.43", + "org.gamecult.cultlib": "1.0.45", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.4", - "org.gamecult.eve.unity-uitoolkit": "0.1.2", + "org.gamecult.eve.surface": "0.3.0", + "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" }, - "hash": "ddd2c5871f935978a0640d43cb58e18579523125" + "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" }, "org.gamecult.eve.unity-uitoolkit": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.eve.surface": "0.2.4" + "org.gamecult.eve.surface": "0.3.0" }, - "hash": "44390549775ec535ee31fc60a5bc594f372f4147" + "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" }, "com.unity.modules.assetbundle": { "version": "1.0.0", From c6b7ecebc56cf3adcb77e41c7f5f6fa36f8e2bdb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 22:43:10 +0200 Subject: [PATCH 08/45] Follow provider-owned Verse navigation --- .../EveUnityCultMeshLiveProviderTransport.cs | 10 ++- .../EveUnityCultMeshPlayableWorldProvider.cs | 29 +++++++- .../EveUnityCultMeshProviderDiscovery.cs | 2 +- .../EveUnityPlayableWorldClientBootstrap.cs | 52 +++++++++++++ .../EveUnityPlayableWorldLiveClient.cs | 35 ++++++++- .../EveUnityCultMeshAssetLookupTests.cs | 4 +- .../EveUnitySceneSurfaceLowererTests.cs | 73 ++++++++++++++++++- .../org.gamecult.eve.unity-scene/package.json | 6 +- 8 files changed, 200 insertions(+), 11 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 4ee1b88..06a3175 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -1389,7 +1389,15 @@ namespace GameCult.Eve.UnityScene receipt.SurfaceId, receipt.Message, DateTimeOffset.TryParse(receipt.IssuedAtUtc, out var issuedAt) ? issuedAt : null, - receipt.SourceVersion)); + receipt.SourceVersion, + receipt.Navigation == null + ? null + : new EveUnitySceneNavigationTarget( + receipt.Navigation.VerseId, + receipt.Navigation.ProviderId, + receipt.Navigation.SurfaceId, + receipt.Navigation.SurfaceKind, + receipt.Navigation.RendezvousEndpoints))); if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) 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 f93be26..3064459 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Stopwatch = System.Diagnostics.Stopwatch; using GameCult.Eve.Surface; @@ -27,7 +28,8 @@ namespace GameCult.Eve.UnityScene IEveUnityCameraRenderPolicySource, IEveUnityNativeAssetProvider, IEveUnityNativeAssetMetadataProvider, - IEveUnityInputCapabilitySource + IEveUnityInputCapabilitySource, + IEveUnityNavigableProvider { [SerializeField] private string rendezvousEndpoint = ""; [SerializeField] private string providerFilter = ""; @@ -116,6 +118,26 @@ namespace GameCult.Eve.UnityScene _bridge?.Disconnect(); } + public async Task NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (string.IsNullOrWhiteSpace(target.VerseId)) + throw new ArgumentException("Eve provider navigation requires a stable Verse identity.", nameof(target)); + if (string.IsNullOrWhiteSpace(target.SurfaceId)) + throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target)); + + ReleaseTransport(); + var navigationEndpoint = target.RendezvousEndpoints + .FirstOrDefault(endpoint => !string.IsNullOrWhiteSpace(endpoint)); + if (!string.IsNullOrWhiteSpace(navigationEndpoint)) + rendezvousEndpoint = navigationEndpoint; + verseFilter = target.VerseId; + providerFilter = target.ProviderId; + surfaceFilter = target.SurfaceId; + surfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) ? "interactive-world" : target.SurfaceKind; + await PrepareAsync(); + } + public void Refresh() { if (Bridge.IsConnected) @@ -156,6 +178,11 @@ namespace GameCult.Eve.UnityScene } private void OnDestroy() + { + ReleaseTransport(); + } + + private void ReleaseTransport() { if (_bridge != null) { 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 91b9aef..62e6837 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -121,7 +121,7 @@ namespace GameCult.Eve.UnityScene return new EveUnityCultMeshProviderSelection( rendezvousEndpoint, - advertisement.Document.ProviderId, + candidate.VerseId, candidate.VerseId, advertisement.Document.ProviderId, advertisement.Surface.SurfaceId, diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index fcd0924..ffe1791 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using UnityEngine; #nullable enable @@ -29,13 +30,64 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; } + private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource; + private bool _navigationInProgress; + public void ConfigureProvider(MonoBehaviour providerComponent) { + UnbindNavigation(); provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent)); providerSurfaceDocuments = provider; commandSink = provider; assetManifestDocuments = provider; receiptSource = provider; + BindNavigation(); + } + + private void OnDestroy() => UnbindNavigation(); + + private void BindNavigation() + { + if (provider is not IEveUnityNavigableProvider || provider is not IEveUnitySceneCommandReceiptSource source) + return; + _navigationReceiptSource = source; + source.ReceiptAvailable += OnReceiptAvailable; + } + + private void UnbindNavigation() + { + if (_navigationReceiptSource != null) + _navigationReceiptSource.ReceiptAvailable -= OnReceiptAvailable; + _navigationReceiptSource = null; + } + + private void OnReceiptAvailable(EveUnitySceneCommandReceipt receipt) + { + if (_navigationInProgress || receipt.Navigation == null) + return; + if (!string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) && + !string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) + return; + StartCoroutine(NavigateAsync(receipt.Navigation)); + } + + private IEnumerator NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (provider is not IEveUnityNavigableProvider navigable) + yield break; + _navigationInProgress = true; + var navigation = navigable.NavigateAsync(target); + while (!navigation.IsCompleted) + yield return null; + try + { + navigation.GetAwaiter().GetResult(); + Mount(); + } + finally + { + _navigationInProgress = false; + } } public EveUnityPlayableWorldPresentation Mount() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index 41ca20f..4fe2ebb 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using GameCult.Eve.Surface; #nullable enable @@ -234,6 +235,34 @@ namespace GameCult.Eve.UnityScene event Action ReceiptAvailable; } + public interface IEveUnityNavigableProvider + { + Task NavigateAsync(EveUnitySceneNavigationTarget target); + } + + public sealed class EveUnitySceneNavigationTarget + { + public EveUnitySceneNavigationTarget( + string verseId, + string providerId, + string surfaceId, + string surfaceKind, + IReadOnlyList? rendezvousEndpoints = null) + { + VerseId = verseId ?? ""; + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + SurfaceKind = surfaceKind ?? ""; + RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty(); + } + + public string VerseId { get; } + public string ProviderId { get; } + public string SurfaceId { get; } + public string SurfaceKind { get; } + public IReadOnlyList RendezvousEndpoints { get; } + } + public sealed class EveUnitySceneCommandReceipt { public EveUnitySceneCommandReceipt( @@ -248,7 +277,8 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string message = "", DateTimeOffset? issuedAtUtc = null, - long sourceVersion = 0) + long sourceVersion = 0, + EveUnitySceneNavigationTarget? navigation = null) { ReceiptId = receiptId ?? ""; Command = command ?? ""; @@ -262,6 +292,7 @@ namespace GameCult.Eve.UnityScene Message = message ?? ""; IssuedAtUtc = issuedAtUtc; SourceVersion = sourceVersion; + Navigation = navigation; } public string Schema { get; } @@ -288,6 +319,8 @@ namespace GameCult.Eve.UnityScene public long SourceVersion { get; } + public EveUnitySceneNavigationTarget? Navigation { get; } + public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal); public bool ShouldRefreshProviderSurface => diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index 7cf72b6..f45bc97 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -19,14 +19,14 @@ namespace GameCult.Eve.UnityScene.Tests { var selection = new EveUnityCultMeshProviderSelection( "cultnet+tcp://odin.example:3075", - "aetheria.daemon", + "aetheria.public", "aetheria.public", "aetheria.daemon", "aetheria.pilot", "interactive-world"); Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075")); - Assert.That(selection.EndpointId, Is.EqualTo("aetheria.daemon")); + Assert.That(selection.EndpointId, Is.EqualTo("aetheria.public")); Assert.That(selection.VerseId, Is.EqualTo("aetheria.public")); Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null); } 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 acae3fe..ffbda32 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Collections; +using System.Threading.Tasks; using GameCult.Caching; using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; @@ -12,6 +14,7 @@ using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.UIElements; +using UnityEngine.TestTools; #nullable enable @@ -912,11 +915,19 @@ namespace GameCult.Eve.UnityScene.Tests "aetheria.daemon.move_intent", "accepted", "Aetheria", - "provider-owned-daemon")); + "provider-owned-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria", + "aetheria.pilot", + "interactive-world"))); Assert.That(runtime.LastReceipt, Is.Not.Null); Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True); Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True); + Assert.That(runtime.LastReceipt.Navigation, Is.Not.Null); + Assert.That(runtime.LastReceipt.Navigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(runtime.LastReceipt.Navigation.SurfaceId, Is.EqualTo("aetheria.pilot")); } [Test] @@ -1134,6 +1145,55 @@ namespace GameCult.Eve.UnityScene.Tests } } + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapFollowsProviderOwnedReceiptNavigation() + { + var hostObject = new GameObject("generic-eve-navigation-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.LastNavigation, Is.Not.Null); + Assert.That(provider.LastNavigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(provider.LastNavigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.example:3076" })); + Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(2)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + [Test] public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector() { @@ -3219,12 +3279,15 @@ namespace GameCult.Eve.UnityScene.Tests IEveUnitySceneCommandSink, IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, - IEveUnityInputCapabilitySource + IEveUnityInputCapabilitySource, + IEveUnityNavigableProvider { public List Submitted { get; } = new List(); public int RefreshCount { get; private set; } + public EveUnitySceneNavigationTarget? LastNavigation { get; private set; } + public string SinkKind => "fake-provider-command-sink"; public string ManifestRef => CurrentDocument.ManifestRef; @@ -3286,6 +3349,12 @@ namespace GameCult.Eve.UnityScene.Tests { ReceiptAvailable?.Invoke(receipt); } + + public Task NavigateAsync(EveUnitySceneNavigationTarget target) + { + LastNavigation = target; + return Task.CompletedTask; + } } } } diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 72c3523..1538e10 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,13 +1,13 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.104", + "version": "0.3.105", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.45", + "org.gamecult.cultlib": "1.0.46", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.0", + "org.gamecult.eve.surface": "0.3.1", "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", From 3af959bf4f90ddfacb94c5846243697110690089 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 05:26:37 +0200 Subject: [PATCH 09/45] Lower inventory placement previews in Unity --- .../Runtime/EveUiToolkitSurfaceLowerer.cs | 59 +++++++++++++++++++ .../Editor/EveUiToolkitSurfaceLowererTests.cs | 49 ++++++++++++++- scripts/run-uitoolkit-tests.ps1 | 26 ++++++-- 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index f6e4fcd..8b82b3d 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -17,6 +17,7 @@ namespace GameCult.Eve.UnityUIToolkit private Vector2 _inventoryDragStart; private bool _inventoryPointerMoved; private bool _suppressInventoryClick; + private readonly List _inventoryPreviewCells = new(); public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null) { @@ -273,6 +274,7 @@ namespace GameCult.Eve.UnityUIToolkit return; if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink)) _inventoryDragSource = null; + ClearInventoryPreview(); }); return grid; } @@ -291,6 +293,7 @@ namespace GameCult.Eve.UnityUIToolkit _inventoryDragSource = component; _inventoryDragStart = evt.position; _inventoryPointerMoved = false; + ClearInventoryPreview(); item.CapturePointer(evt.pointerId); evt.StopPropagation(); }); @@ -300,7 +303,15 @@ namespace GameCult.Eve.UnityUIToolkit return; var pointerPosition = new Vector2(evt.position.x, evt.position.y); if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f) + { _inventoryPointerMoved = true; + var picked = item.panel?.Pick(evt.position); + var targetElement = InventoryGridAncestor(picked); + if (targetElement?.userData is EveSurfaceComponent target) + ShowInventoryPreview(component, target, targetElement, evt.position); + else + ClearInventoryPreview(); + } }); item.RegisterCallback(evt => { @@ -315,6 +326,7 @@ namespace GameCult.Eve.UnityUIToolkit TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink); _inventoryDragSource = null; _suppressInventoryClick = true; + ClearInventoryPreview(); } evt.StopPropagation(); }); @@ -399,6 +411,9 @@ namespace GameCult.Eve.UnityUIToolkit Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f))); var x = Math.Max(0, (int)Math.Floor(local.x / pitch)); var y = Math.Max(0, (int)Math.Floor(local.y / pitch)); + if (!EveInventoryInteraction.TryCreatePlacementPreview(source, target, x, y, out var preview) || + preview == null || !preview.IsValid) + return false; if (!EveInventoryInteraction.TryCreateDropRequest( document, source, target, x, y, "unity-uitoolkit", out var request) || request == null) return false; @@ -406,6 +421,50 @@ namespace GameCult.Eve.UnityUIToolkit return true; } + private void ShowInventoryPreview( + EveSurfaceComponent source, + EveSurfaceComponent target, + VisualElement targetElement, + Vector2 panelPosition) + { + ClearInventoryPreview(); + var local = targetElement.WorldToLocal(panelPosition); + var cellSize = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f)); + var gap = Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f)); + var pitch = cellSize + gap; + var x = Math.Max(0, (int)Math.Floor(local.x / Math.Max(1f, pitch))); + var y = Math.Max(0, (int)Math.Floor(local.y / Math.Max(1f, pitch))); + if (!EveInventoryInteraction.TryCreatePlacementPreview(source, target, x, y, out var preview) || + preview == null) + return; + + foreach (var cell in preview.Cells) + { + var element = new VisualElement { pickingMode = PickingMode.Ignore }; + element.AddToClassList("eve-inventory-placement-preview"); + element.AddToClassList(preview.IsValid + ? "eve-inventory-placement-valid" + : "eve-inventory-placement-invalid"); + element.style.position = Position.Absolute; + element.style.left = cell.X * pitch; + element.style.top = cell.Y * pitch; + element.style.width = cellSize; + element.style.height = cellSize; + element.style.backgroundColor = preview.IsValid + ? new Color(0.2f, 0.9f, 0.65f, 0.34f) + : new Color(1f, 0.25f, 0.2f, 0.38f); + targetElement.Add(element); + _inventoryPreviewCells.Add(element); + } + } + + private void ClearInventoryPreview() + { + foreach (var cell in _inventoryPreviewCells) + cell.RemoveFromHierarchy(); + _inventoryPreviewCells.Clear(); + } + private static int ParseInt(string value, int fallback) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs index 93b83c5..85d664b 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using GameCult.Eve.Surface; using GameCult.Mesh; using NUnit.Framework; @@ -244,18 +245,64 @@ namespace GameCult.Eve.UnityUIToolkit.Tests ["targetKind"] = "equipment", ["targetEntityKey"] = "zone.0.entity.1", ["targetIndex"] = "-1", - ["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem" + ["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem", + ["payload.shipId"] = "hangar.ship.1", + ["payload.expectedHangarRevision"] = "42" }); Assert.That(EveInventoryInteraction.TryCreateDropRequest( Document(target), source, target, 7, 9, "unity-test", out var request), Is.True); Assert.That(request, Is.Not.Null); Assert.That(request!.Command, Is.EqualTo("aetheria.daemon.commands.EquipItem")); + Assert.That(request.CommandId, Is.Not.Empty); Assert.That(request.Payload.GetString("originEntityKey"), Is.EqualTo("zone.0.entity.1")); Assert.That(request.Payload.GetString("originCargoIndex"), Is.EqualTo("2")); Assert.That(request.Payload.GetString("destinationX"), Is.EqualTo("7")); Assert.That(request.Payload.GetString("destinationY"), Is.EqualTo("9")); Assert.That(request.Payload.GetString("hasDestinationPosition"), Is.EqualTo("true")); + Assert.That(request.Payload.GetString("shipId"), Is.EqualTo("hangar.ship.1")); + Assert.That(request.Payload.GetString("expectedHangarRevision"), Is.EqualTo("42")); + } + + [Test] + public void InventoryPlacementPreviewUsesIrregularShapeAndCurrentOccupancy() + { + var moving = Component("moving", EveInventoryInteraction.ItemKind, new Dictionary + { + ["shapeCells"] = "0,0;1,0;0,1" + }); + var installed = Component("installed", EveInventoryInteraction.ItemKind, new Dictionary + { + ["x"] = "2", + ["y"] = "1", + ["shapeCells"] = "0,0" + }); + var grid = new EveSurfaceComponent( + "grid", + EveInventoryInteraction.GridKind, + new Dictionary + { + ["columns"] = "4", + ["rows"] = "3", + ["validCells"] = "0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1;0,2;1,2;2,2;3,2" + }, + new[] { installed }); + + Assert.That(EveInventoryInteraction.TryCreatePlacementPreview( + moving, grid, 0, 0, out var valid), Is.True); + Assert.That(valid!.IsValid, Is.True); + Assert.That(valid.Cells.Select(cell => (cell.X, cell.Y)), + Is.EquivalentTo(new[] { (0, 0), (1, 0), (0, 1) })); + + Assert.That(EveInventoryInteraction.TryCreatePlacementPreview( + moving, grid, 1, 1, out var occupied), Is.True); + Assert.That(occupied!.IsValid, Is.False); + Assert.That(occupied.Reason, Is.EqualTo("occupied")); + + Assert.That(EveInventoryInteraction.TryCreatePlacementPreview( + moving, grid, 3, 2, out var outside), Is.True); + Assert.That(outside!.IsValid, Is.False); + Assert.That(outside.Reason, Is.EqualTo("outside-grid")); } [Test] diff --git a/scripts/run-uitoolkit-tests.ps1 b/scripts/run-uitoolkit-tests.ps1 index d34cb92..12b317a 100644 --- a/scripts/run-uitoolkit-tests.ps1 +++ b/scripts/run-uitoolkit-tests.ps1 @@ -14,9 +14,21 @@ foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) { if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity UI Toolkit test path not found: $required" } } -powershell -ExecutionPolicy Bypass -File $cultLibBuilder +& $cultLibBuilder if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" } +$builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib" +$stagedDependenciesRoot = Join-Path $repoRoot "artifacts\test-dependencies" +$stagedCultLibPackage = Join-Path $stagedDependenciesRoot "org.gamecult.cultlib" +if (-not (Test-Path -LiteralPath $builtCultLibPackage)) { + throw "CultLib Unity package build did not produce: $builtCultLibPackage" +} +New-Item -ItemType Directory -Force -Path $stagedDependenciesRoot | Out-Null +if (Test-Path -LiteralPath $stagedCultLibPackage) { + [IO.Directory]::Delete($stagedCultLibPackage, $true) +} +Copy-Item -LiteralPath $builtCultLibPackage -Destination $stagedCultLibPackage -Recurse + $runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss") New-Item -ItemType Directory -Force -Path $runRoot | Out-Null $resultsPath = Join-Path $runRoot "unity-editmode-results.xml" @@ -27,10 +39,16 @@ $arguments = @( "-assemblyNames", "GameCult.Eve.UnityUIToolkit.Tests", "-testResults", $resultsPath, "-logFile", $logPath ) -$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden -if ($process.ExitCode -ne 0) { +$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -PassThru -WindowStyle Hidden +if (-not $process.WaitForExit([int][TimeSpan]::FromMinutes(10).TotalMilliseconds)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $logPath) { Get-Content -LiteralPath $logPath -Tail 120 } - throw "EveUnity UI Toolkit tests failed with exit code $($process.ExitCode)" + throw "EveUnity UI Toolkit tests timed out after 10 minutes (Unity PID $($process.Id))." +} +$unityExitCode = $process.ExitCode +if ($unityExitCode -ne 0) { + if (Test-Path -LiteralPath $logPath) { Get-Content -LiteralPath $logPath -Tail 120 } + throw "EveUnity UI Toolkit tests failed with exit code $unityExitCode" } [xml]$results = Get-Content -LiteralPath $resultsPath -Raw From 136d705707fbe8b9a5d7f959654535cf6506147d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 05:31:10 +0200 Subject: [PATCH 10/45] Release Unity inventory placement lowering --- packages/org.gamecult.eve.unity-uitoolkit/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/org.gamecult.eve.unity-uitoolkit/package.json b/packages/org.gamecult.eve.unity-uitoolkit/package.json index e28b919..f4f63dc 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/package.json +++ b/packages/org.gamecult.eve.unity-uitoolkit/package.json @@ -1,11 +1,11 @@ { "name": "org.gamecult.eve.unity-uitoolkit", "displayName": "Eve UI Toolkit Lowering", - "version": "0.1.3", + "version": "0.1.4", "unity": "2021.3", "description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.", "dependencies": { - "org.gamecult.eve.surface": "0.3.0" + "org.gamecult.eve.surface": "0.3.2" }, "keywords": [ "Eve", From 83f9d2e0cc7fec6049104f6a7a7e5235d4868696 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 05:32:32 +0200 Subject: [PATCH 11/45] Align Unity scene lowering dependencies --- packages/org.gamecult.eve.unity-scene/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 1538e10..b2c2a36 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,14 +1,14 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.105", + "version": "0.3.106", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { "org.gamecult.cultlib": "1.0.46", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.1", - "org.gamecult.eve.unity-uitoolkit": "0.1.3", + "org.gamecult.eve.surface": "0.3.2", + "org.gamecult.eve.unity-uitoolkit": "0.1.4", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" From f67632233ac8dcd8ae92428ac35a475a8acd25a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 07:27:13 +0200 Subject: [PATCH 12/45] Carry CultMesh Verse and provider identity separately --- .../PlayMode/GenericWorldCaptureTests.cs | 10 ++- .../PlayMode/GenericWorldCaptureTests.cs | 10 ++- .../EveUnityCultMeshLiveProviderTransport.cs | 42 +++++----- .../EveUnityCultMeshPlayableWorldProvider.cs | 3 +- .../EveUnityCultMeshProviderDiscovery.cs | 79 ++++++++++--------- .../EveUnityCultMeshAssetLookupTests.cs | 3 +- .../EveUnitySceneSurfaceLowererTests.cs | 5 ++ 7 files changed, 87 insertions(+), 65 deletions(-) diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 0bfa008..8d8cef4 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); var quicProof = ReceiveAdvertisedRealtimeFrameAsync( rendezvousEndpoint, - provider.Selection.VerseId); + provider.Selection.VerseId, + provider.Selection.AuthorityRuntimeId); var quicDeadline = Time.realtimeSinceStartup + 15f; while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) yield return new WaitForSecondsRealtime(0.05f); @@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests private static async Task ReceiveAdvertisedRealtimeFrameAsync( string rendezvousEndpoint, - string verseId) + string verseId, + string authorityRuntimeId) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var client = new CultMeshClient(new CultMeshClientOptions @@ -1211,7 +1213,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests new CultMeshNativeQuicRealtimeTransportConnector() } }); - using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token); + using var session = await client.ConnectRealtimeAsync( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + timeout.Token); return await session.ReceiveAsync(timeout.Token); } diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 0bfa008..8d8cef4 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); var quicProof = ReceiveAdvertisedRealtimeFrameAsync( rendezvousEndpoint, - provider.Selection.VerseId); + provider.Selection.VerseId, + provider.Selection.AuthorityRuntimeId); var quicDeadline = Time.realtimeSinceStartup + 15f; while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) yield return new WaitForSecondsRealtime(0.05f); @@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests private static async Task ReceiveAdvertisedRealtimeFrameAsync( string rendezvousEndpoint, - string verseId) + string verseId, + string authorityRuntimeId) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var client = new CultMeshClient(new CultMeshClientOptions @@ -1211,7 +1213,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests new CultMeshNativeQuicRealtimeTransportConnector() } }); - using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token); + using var session = await client.ConnectRealtimeAsync( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + timeout.Token); return await session.ReceiveAsync(timeout.Token); } 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 06a3175..26c22db 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -26,7 +26,7 @@ namespace GameCult.Eve.UnityScene { private readonly string _cachePath; private readonly string _rendezvousEndpoint; - private readonly string _endpointId; + private readonly CultMeshSessionTarget _target; private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; @@ -83,7 +83,8 @@ namespace GameCult.Eve.UnityScene public EveUnityCultMeshLiveProviderTransport( string cachePath, string rendezvousEndpoint, - string endpointId, + string verseId, + string authorityRuntimeId, string providerId, string surfaceId, string runtimeId = "eve-unity", @@ -95,9 +96,7 @@ namespace GameCult.Eve.UnityScene _rendezvousEndpoint = string.IsNullOrWhiteSpace(rendezvousEndpoint) ? throw new ArgumentException("CultMesh rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)) : rendezvousEndpoint.Trim(); - _endpointId = string.IsNullOrWhiteSpace(endpointId) - ? throw new ArgumentException("CultMesh stable endpoint identity must be non-empty.", nameof(endpointId)) - : endpointId.Trim(); + _target = new CultMeshSessionTarget(verseId, authorityRuntimeId); _providerId = string.IsNullOrWhiteSpace(providerId) ? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)) : providerId.Trim(); @@ -189,7 +188,7 @@ namespace GameCult.Eve.UnityScene catch (Exception error) when (error is IOException || error is SocketException || error is TimeoutException) { throw new InvalidOperationException( - $"CultMesh provider '{_providerId}' bootstrap through stable endpoint '{_endpointId}' failed.", + $"CultMesh provider '{_providerId}' bootstrap through target '{_target}' failed.", error); } } @@ -361,7 +360,7 @@ namespace GameCult.Eve.UnityScene { var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); _meshClient!.SubmitDocumentAsync( - _endpointId, + _target, recordKey, request, _runtimeId, @@ -466,7 +465,7 @@ namespace GameCult.Eve.UnityScene if (!forceRefresh && _advertisement != null && _advertisedSurface != null) return; using var advertisements = _meshClient! - .LeaseCollectionAsync(_endpointId) + .LeaseCollectionAsync(_target) .GetAwaiter() .GetResult(); _advertisement = advertisements.Handle.LatestAsync().GetAwaiter().GetResult().FirstOrDefault(document => @@ -490,7 +489,7 @@ namespace GameCult.Eve.UnityScene var mappedRoot = _cachePath; var networkBodies = _meshClient!.BodyProvider( _advertisement.ProviderId, - _advertisement.ServiceId, + _target, new CultMeshSessionBodyProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(2) }); return new CultMeshBodyPublicationResolver(new CultMeshBodyTransportService( new ICultMeshBodyTransportAdapter[] @@ -604,19 +603,20 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException( $"Eve provider '{_providerId}' does not advertise the Verse identity required for realtime state."); _realtimeLifetime = new CancellationTokenSource(); - _realtimePump = Task.Run(() => PumpRealtimeEntityStateAsync( - _advertisement.VerseId, - _realtimeLifetime.Token)); + if (!string.Equals(_advertisement.VerseId, _target.VerseId, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Eve provider '{_providerId}' advertisement Verse '{_advertisement.VerseId}' does not match selected Verse '{_target.VerseId}'."); + _realtimePump = Task.Run(() => PumpRealtimeEntityStateAsync(_realtimeLifetime.Token)); } - private async Task PumpRealtimeEntityStateAsync(string verseId, CancellationToken cancellationToken) + private async Task PumpRealtimeEntityStateAsync(CancellationToken cancellationToken) { try { _realtimeSession = await _meshClient! - .ConnectRealtimeAsync(verseId, cancellationToken) + .ConnectRealtimeAsync(_target, cancellationToken) .ConfigureAwait(false); - TraceHotState($"realtime state connected transport={_realtimeSession.TransportId} verse={verseId}"); + TraceHotState($"realtime state connected transport={_realtimeSession.TransportId} target={_target}"); while (!cancellationToken.IsCancellationRequested) { var frame = await _realtimeSession.ReceiveAsync(cancellationToken).ConfigureAwait(false); @@ -823,7 +823,7 @@ namespace GameCult.Eve.UnityScene private void RefreshSurface() { var surface = _meshClient! - .ReadAsync(_endpointId, _advertisedSurface!.RecordRef) + .ReadAsync(_target, _advertisedSurface!.RecordRef) .GetAwaiter() .GetResult(); PublishBaseSurface(surface); @@ -1010,7 +1010,7 @@ namespace GameCult.Eve.UnityScene bool publishInitial = true) where TDocument : class { - var lease = _meshClient!.LeaseDocumentAsync(_endpointId, recordKey) + var lease = _meshClient!.LeaseDocumentAsync(_target, recordKey) .GetAwaiter().GetResult(); _documentLeases.Add(lease); if (publishInitial) publish(lease.Handle.Latest()); @@ -1022,7 +1022,7 @@ namespace GameCult.Eve.UnityScene bool includeInitialSnapshot = true) where TDocument : class { - var lease = _meshClient!.LeaseCollectionAsync(_endpointId, includeInitialSnapshot) + var lease = _meshClient!.LeaseCollectionAsync(_target, includeInitialSnapshot) .GetAwaiter().GetResult(); _documentLeases.Add(lease); if (includeInitialSnapshot) @@ -1068,7 +1068,7 @@ namespace GameCult.Eve.UnityScene return; var catalog = _meshClient! - .ReadAsync(_endpointId, interaction.AssetManifestRecordRef) + .ReadAsync(_target, interaction.AssetManifestRecordRef) .GetAwaiter() .GetResult(); PublishAssetCatalog(catalog); @@ -1157,7 +1157,7 @@ namespace GameCult.Eve.UnityScene EnsureBundleLoaded(dependency); var groupElapsed = Stopwatch.StartNew(); - var descriptor = _meshClient!.ReadAsync(_endpointId, uri) + var descriptor = _meshClient!.ReadAsync(_target, uri) .GetAwaiter() .GetResult(); TraceStartup("asset-manifest", groupElapsed); @@ -1280,7 +1280,7 @@ namespace GameCult.Eve.UnityScene { _meshClient!.ContentProvider( _advertisement.ProviderId, - _advertisement.ServiceId) + _target) }, new CultMeshContentTransferOptions(cacheRoot), _assetBodyMappings); 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 3064459..1e71df7 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -250,7 +250,8 @@ namespace GameCult.Eve.UnityScene _transport = new EveUnityCultMeshLiveProviderTransport( resolvedCachePath, Selection.RendezvousEndpoint, - Selection.EndpointId, + Selection.VerseId, + Selection.AuthorityRuntimeId, Selection.ProviderId, Selection.SurfaceId, runtimeId); 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 62e6837..74d3040 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -14,23 +14,23 @@ namespace GameCult.Eve.UnityScene { public EveUnityCultMeshProviderSelection( string rendezvousEndpoint, - string endpointId, string verseId, + string authorityRuntimeId, string providerId, string surfaceId, string surfaceKind) { RendezvousEndpoint = rendezvousEndpoint ?? ""; - EndpointId = endpointId ?? ""; VerseId = verseId ?? ""; + AuthorityRuntimeId = authorityRuntimeId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; } public string RendezvousEndpoint { get; } - public string EndpointId { get; } public string VerseId { get; } + public string AuthorityRuntimeId { get; } public string ProviderId { get; } public string SurfaceId { get; } public string SurfaceKind { get; } @@ -95,41 +95,48 @@ namespace GameCult.Eve.UnityScene }); foreach (var candidate in candidates) { - try + foreach (var authorityRuntimeId in (candidate.AuthorityRuntimeIds ?? Array.Empty()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal)) { - using var advertisementsLease = await mesh - .LeaseCollectionAsync(candidate.VerseId) - .ConfigureAwait(false); - var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false); - observed.AddRange(advertisements.Select(document => - $"{candidate.VerseId}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); - var advertisement = advertisements - .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; + try + { + var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId); + using var advertisementsLease = await mesh + .LeaseCollectionAsync(target) + .ConfigureAwait(false); + var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false); + observed.AddRange(advertisements.Select(document => + $"{target}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); + var advertisement = advertisements + .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( - rendezvousEndpoint, - candidate.VerseId, - candidate.VerseId, - advertisement.Document.ProviderId, - advertisement.Surface.SurfaceId, - advertisement.Surface.SurfaceKind); - } - catch (Exception error) - { - failures.Add($"{candidate.VerseId}: {error.Message}"); + return new EveUnityCultMeshProviderSelection( + rendezvousEndpoint, + candidate.VerseId, + authorityRuntimeId, + advertisement.Document.ProviderId, + advertisement.Surface.SurfaceId, + advertisement.Surface.SurfaceKind); + } + catch (Exception error) + { + failures.Add($"{candidate.VerseId}/{authorityRuntimeId}: {error.Message}"); + } } } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index f45bc97..5f1d489 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -26,8 +26,8 @@ namespace GameCult.Eve.UnityScene.Tests "interactive-world"); Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075")); - Assert.That(selection.EndpointId, Is.EqualTo("aetheria.public")); Assert.That(selection.VerseId, Is.EqualTo("aetheria.public")); + Assert.That(selection.AuthorityRuntimeId, Is.EqualTo("aetheria.public")); Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null); } @@ -180,6 +180,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet+tcp://127.0.0.1:1", "provider", "provider", + "provider", "surface"); var variant = new EveAssetVariant( "unity-scene", 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 ffbda32..f6312b8 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -32,6 +32,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "aetheria", "aetheria", + "aetheria", "aetheria.pilot"); }); } @@ -67,6 +68,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "aetheria", "aetheria", + "aetheria", "aetheria.pilot"); var view = EntityLeaseDocument(); var publication = BodyPublication(view, view.Sequence + 1); @@ -102,6 +104,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "aetheria", "aetheria", + "aetheria", "aetheria.pilot"); var view = EntityLeaseDocument(); var publication = BodyPublication(view, view.Sequence); @@ -131,6 +134,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "provider", "provider", + "provider", "provider.pilot"); var enqueue = typeof(EveUnityCultMeshLiveProviderTransport) .GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -161,6 +165,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "provider", "provider", + "provider", "provider.pilot"); var view = EntityLeaseDocument(); view.Sequence = 0; From a51723bd5720b72b3e0184c63de2911bc69b63e9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 10:08:03 +0200 Subject: [PATCH 13/45] Keep CultMesh I/O off the Unity main thread --- docs/authority-map.md | 10 +- .../org.gamecult.eve.unity-scene/README.md | 29 +- .../CultMesh/EveUnityCultMeshCommandOutbox.cs | 193 ++++++++++ .../EveUnityCultMeshCommandOutbox.cs.meta | 3 + .../EveUnityCultMeshLiveProviderTransport.cs | 345 +++++++++++------- .../EveUnityCultMeshPlayableWorldProvider.cs | 29 +- .../EveUnityCultMeshProviderDiscovery.cs | 16 +- .../EveUnityCultMeshAssetLookupTests.cs | 111 ++++++ .../org.gamecult.eve.unity-scene/package.json | 2 +- 9 files changed, 580 insertions(+), 158 deletions(-) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta diff --git a/docs/authority-map.md b/docs/authority-map.md index 4b25203..7a339b4 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -76,6 +76,10 @@ Unity code may not open provider snapshot sessions, schema clients, write forwarders, subscriptions, or replica shards against a discovered physical route. Reads, watches, content/body transfer, realtime frames, and command submissions share the same stable-identity `CultMeshClient` owner. +Remote bootstrap and asset materialization complete through `PrepareAsync` +before the scene is mounted. `Connect`, `Mount`, input, refresh, and disposal +cannot synchronously wait on CultMesh. A bounded command outbox owns delivery; +only unsent continuous movement/look values may be replaced by a newer value. Viewport-sized shader inputs are likewise presentation state. A provider may relate a logical vector port to a logical native texture port through @@ -98,8 +102,10 @@ modes into one camera opinion or infer a product camera from entity kind. ## Shared Paths -Initial connect asks one rendezvous for Verses, selects a provider surface by -semantic kind, then leases it through the selected stable provider identity. +Initial preparation asks one rendezvous for Verses, selects an advertised +authority runtime and provider surface by semantic kind, then leases it through +the explicit Verse/authority-runtime target. The Eve provider ID remains a +separate surface-owner identity. Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain inside the same identity-owned session. Movement, focus, target, and action input all become `gamecult.eve.command_invocation.v1` through the diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 6edac13..e074c87 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -16,20 +16,23 @@ owned by their native lowerers and are not duplicated into empty UI elements. networked client. Give it one Verse rendezvous endpoint and it discovers an advertised `interactive-world`, constructs the generic CultMesh transport, and provides every bootstrap port. Provider, surface, and Verse IDs are optional -selection filters; they are not product knowledge required by the client. -The selected provider ID remains the stable CultMesh endpoint identity while -the Verse ID names the selected rule/progression world. Physical routes are -resolved and re-resolved inside `CultMeshClient`; they are never -published as provider selection state or cached as Unity authority. The optional -local path is only an ephemeral content/body cache, not a replicated gameplay -database. +selection filters; they are not product knowledge required by the client. The +selected Verse ID and its advertised authority-runtime ID form the stable +CultMesh session target. The Eve provider ID separately names the UI owner found +through that target. Physical routes are resolved and re-resolved inside +`CultMeshClient`; they are never published as provider selection state or +cached as Unity authority. The optional local path is only an ephemeral +content/body cache, not a replicated gameplay database. Await `PrepareAsync()` before mounting the bootstrap or reading provider ports. -Discovery and session setup never run synchronously from Unity lifecycle or -input getters, so an unavailable provider cannot stall the editor main thread. -Live entity, field, and receipt polling likewise runs as one non-overlapping -background operation. Unity's main thread only applies completed typed -documents, keeping Play, Pause, inspection, and rendering responsive while a -provider is slow or temporarily unreachable. +It owns discovery, initial document reads, content transfer, asynchronous asset +bundle loading, and live lease installation. Once preparation succeeds, +`Mount()` and `Connect()` perform no remote work. Input submission only enters a +bounded asynchronous outbox: one-shot commands retain their idempotency keys, +while unsent movement and look values coalesce to the latest value per entity. +Live entity, field, and receipt polling likewise remains non-blocking. Unity's +main thread applies completed typed documents and assets; it never waits for a +CultMesh task, keeping Play, Pause, input, inspection, and rendering responsive +while a provider is slow or temporarily unreachable. The package never imports provider product assemblies. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs new file mode 100644 index 0000000..6a93b30 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + /// + /// Owns asynchronous command delivery for the CultMesh Unity transport. + /// The Unity input path only enqueues. One-shot commands retain their + /// idempotency key; continuous controls retain only the newest unsent value. + /// + internal sealed class EveUnityCultMeshCommandOutbox : IDisposable + { + private readonly object _gate = new object(); + private readonly Queue _commands = new Queue(); + private readonly Dictionary _continuous = + new Dictionary(StringComparer.Ordinal); + private readonly Func _send; + private readonly Func _continuousKey; + private readonly Action _discard; + private readonly int _commandCapacity; + private readonly int _continuousCapacity; + private readonly SemaphoreSlim _available = new SemaphoreSlim(0); + private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); + private readonly Task _worker; + private bool _disposed; + private int _inFlight; + + public EveUnityCultMeshCommandOutbox( + Func send, + Func continuousKey, + Action? discard = null, + int commandCapacity = 256, + int continuousCapacity = 32) + { + _send = send ?? throw new ArgumentNullException(nameof(send)); + _continuousKey = continuousKey ?? throw new ArgumentNullException(nameof(continuousKey)); + _discard = discard ?? (_ => { }); + if (commandCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(commandCapacity)); + if (continuousCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(continuousCapacity)); + _commandCapacity = commandCapacity; + _continuousCapacity = continuousCapacity; + _worker = Task.Run(DeliverAsync); + } + + internal int PendingCount + { + get + { + lock (_gate) return _commands.Count + _continuous.Count + _inFlight; + } + } + + public void Enqueue(EveSurfaceCommandRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.CommandId)) + throw new InvalidOperationException("Eve command invocations require an idempotency key."); + + string? discarded = null; + var signal = false; + lock (_gate) + { + if (_disposed) throw new ObjectDisposedException(nameof(EveUnityCultMeshCommandOutbox)); + var key = _continuousKey(request); + var item = new OutboxItem(request, string.IsNullOrWhiteSpace(key) ? null : key); + if (string.IsNullOrWhiteSpace(key)) + { + if (_commands.Count >= _commandCapacity) + throw new InvalidOperationException( + $"The EveUnity command outbox reached its {_commandCapacity}-command bound."); + _commands.Enqueue(item); + signal = true; + } + else if (_continuous.TryGetValue(key, out var previous)) + { + _continuous[key] = item; + discarded = previous.Request.CommandId; + } + else + { + if (_continuous.Count >= _continuousCapacity) + throw new InvalidOperationException( + $"The EveUnity continuous-control outbox reached its {_continuousCapacity}-binding bound."); + _continuous.Add(key, item); + signal = true; + } + } + + if (!string.IsNullOrWhiteSpace(discarded)) _discard(discarded); + if (signal) _available.Release(); + } + + public void Dispose() + { + OutboxItem[] discarded; + lock (_gate) + { + if (_disposed) return; + _disposed = true; + discarded = _commands.Concat(_continuous.Values).ToArray(); + _commands.Clear(); + _continuous.Clear(); + } + foreach (var item in discarded) _discard(item.Request.CommandId); + _lifetime.Cancel(); + _available.Release(); + } + + private async Task DeliverAsync() + { + var cancellationToken = _lifetime.Token; + try + { + while (true) + { + await _available.WaitAsync(cancellationToken).ConfigureAwait(false); + var item = TakeNext(); + if (item == null) continue; + try + { + var retryDelay = 50; + while (true) + { + try + { + await _send(item.Request, cancellationToken).ConfigureAwait(false); + break; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + if (WasSuperseded(item)) break; + await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); + retryDelay = Math.Min(retryDelay * 2, 2000); + } + } + } + finally + { + lock (_gate) _inFlight--; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private OutboxItem? TakeNext() + { + lock (_gate) + { + if (_commands.Count > 0) + { + _inFlight++; + return _commands.Dequeue(); + } + if (_continuous.Count == 0) return null; + var pair = _continuous.First(); + _continuous.Remove(pair.Key); + _inFlight++; + return pair.Value; + } + } + + private bool WasSuperseded(OutboxItem item) + { + if (item.ContinuousKey == null) return false; + lock (_gate) return _continuous.ContainsKey(item.ContinuousKey); + } + + private sealed class OutboxItem + { + public OutboxItem(EveSurfaceCommandRequest request, string? continuousKey) + { + Request = request; + ContinuousKey = continuousKey; + } + + public EveSurfaceCommandRequest Request { get; } + public string? ContinuousKey { get; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta new file mode 100644 index 0000000..a6c7e7a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 61d76e98f2054d86bf8e7480d4411c39 +timeCreated: 1787039650 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 26c22db..31922e8 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -45,6 +45,7 @@ namespace GameCult.Eve.UnityScene new Dictionary(StringComparer.Ordinal); private readonly HashSet _loadedBundleUris = new HashSet(StringComparer.Ordinal); private readonly HashSet _loadingBundleUris = new HashSet(StringComparer.Ordinal); + private readonly HashSet _continuousCommandIds = new HashSet(StringComparer.Ordinal); private readonly List _assetBodyLeases = new List(); private readonly Dictionary _renderChannelLayers = new Dictionary(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); @@ -57,6 +58,7 @@ namespace GameCult.Eve.UnityScene new Dictionary(StringComparer.Ordinal); private readonly List _documentLeases = new List(); private readonly List _documentWatches = new List(); + private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); private CultMeshClient? _meshClient; private bool _liveDocumentsReady; private CultCache? _contentState; @@ -73,6 +75,9 @@ namespace GameCult.Eve.UnityScene private CancellationTokenSource? _realtimeLifetime; private Task? _realtimePump; private CultMeshRealtimeSession? _realtimeSession; + private EveUnityCultMeshCommandOutbox? _commandOutbox; + private EveAssetCatalogDocument? _pendingAssetCatalog; + private bool _assetCatalogUpdateRunning; private long _lastQueuedEntityViewEpoch = -1; private long _lastQueuedEntityViewSequence = -1; private long _lastPresentedEntityViewEpoch = -1; @@ -147,8 +152,7 @@ namespace GameCult.Eve.UnityScene public void Connect() { - EnsureOpen(); - BootstrapIfRequired(); + EnsurePrepared(); } public void Disconnect() @@ -157,32 +161,33 @@ namespace GameCult.Eve.UnityScene public void Refresh() { - EnsureOpen(); - if (_bootstrapped) - { - PumpLiveEvents(); - return; - } - - BootstrapIfRequired(); + EnsurePrepared(); + PumpLiveEvents(); } - private void BootstrapIfRequired() + public async Task PrepareAsync(CancellationToken cancellationToken = default) { if (_bootstrapped) return; try { + EnsureOpen(); var elapsed = Stopwatch.StartNew(); - ResolveAdvertisement(forceRefresh: true); + await ResolveAdvertisementAsync(forceRefresh: true, cancellationToken); TraceStartup("advertisement", elapsed); - RefreshSurface(); + await RefreshSurfaceAsync(cancellationToken); TraceStartup("surface", elapsed); - RefreshAssetCatalog(); + await RefreshAssetCatalogAsync(cancellationToken); TraceStartup("asset-catalog-and-bundles", elapsed); - EnsureLiveDocuments(); + await PreloadAssetsAsync(cancellationToken); + TraceStartup("asset-preload", elapsed); + await EnsureLiveDocumentsAsync(cancellationToken); TraceStartup("subscriptions", elapsed); + _commandOutbox = new EveUnityCultMeshCommandOutbox( + SendCommandAsync, + ContinuousCommandKey, + ForgetPendingCommand); _bootstrapped = true; } catch (Exception error) when (error is IOException || error is SocketException || error is TimeoutException) @@ -193,6 +198,13 @@ namespace GameCult.Eve.UnityScene } } + private void EnsurePrepared() + { + if (!_bootstrapped) + throw new InvalidOperationException( + "The EveUnity CultMesh transport is not prepared. Await PrepareAsync before mounting or connecting it."); + } + private static void TraceStartup(string phase, Stopwatch elapsed) { if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal)) @@ -234,7 +246,7 @@ namespace GameCult.Eve.UnityScene else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; else if (document is EveAssetCatalogDocument assetCatalog) - PublishAssetCatalog(assetCatalog); + QueueAssetCatalogUpdate(assetCatalog); else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); else if (document is Exception error) @@ -340,8 +352,7 @@ namespace GameCult.Eve.UnityScene public void SubmitCommand(EveSurfaceCommandRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); - EnsureOpen(); - ResolveAdvertisement(); + EnsurePrepared(); var interaction = RequireWorldInteraction(); if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef)) throw new InvalidOperationException("The provider advertisement does not publish a command record reference."); @@ -355,29 +366,48 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(commandId)) throw new InvalidOperationException("Eve command invocations require an idempotency key."); - _pendingCommandIds.Add(commandId); - try - { - var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); - _meshClient!.SubmitDocumentAsync( - _target, - recordKey, - request, - _runtimeId, - "eve-unity") - .GetAwaiter() - .GetResult(); - } + lock (_pendingCommandIds) _pendingCommandIds.Add(commandId); + try { _commandOutbox!.Enqueue(request); } catch { - _pendingCommandIds.Remove(commandId); + ForgetPendingCommand(commandId); throw; } } + private Task SendCommandAsync(EveSurfaceCommandRequest request, CancellationToken cancellationToken) + { + var interaction = RequireWorldInteraction(); + var recordKey = ChildRecordKey(interaction.CommandRecordRef, request.CommandId); + return _meshClient!.SubmitDocumentAsync( + _target, + recordKey, + request, + _runtimeId, + "eve-unity", + cancellationToken); + } + + private string? ContinuousCommandKey(EveSurfaceCommandRequest request) + { + if (!request.PayloadFields.TryGetValue("commandId", out var commandId) || + !_continuousCommandIds.Contains(commandId)) + return null; + request.PayloadFields.TryGetValue("entityId", out var entityId); + return commandId + "\u001f" + (entityId ?? ""); + } + + private void ForgetPendingCommand(string commandId) + { + lock (_pendingCommandIds) _pendingCommandIds.Remove(commandId); + } + public void Dispose() { _disposed = true; + _lifetime.Cancel(); + _commandOutbox?.Dispose(); + _commandOutbox = null; foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); _assetBundles.Clear(); foreach (var lease in _assetBodyLeases) lease.Dispose(); @@ -396,11 +426,6 @@ namespace GameCult.Eve.UnityScene _realtimeLifetime?.Cancel(); _realtimeSession?.Dispose(); _realtimeSession = null; - if (_realtimePump != null) - { - try { _realtimePump.GetAwaiter().GetResult(); } - catch (OperationCanceledException) { } - } _realtimePump = null; _realtimeLifetime?.Dispose(); _realtimeLifetime = null; @@ -413,14 +438,18 @@ namespace GameCult.Eve.UnityScene _assetBodyMappings = null; _baseSurface = null; _embeddedSurfaces.Clear(); - _pendingCommandIds.Clear(); - _publishedReceiptIds.Clear(); + lock (_pendingCommandIds) + { + _pendingCommandIds.Clear(); + _publishedReceiptIds.Clear(); + } + _lifetime.Dispose(); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { if (asset == null) throw new ArgumentNullException(nameof(asset)); - EnsureAssetLoaded(asset.AssetRef); + EnsureAssetPrepared(asset.AssetRef); return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; } @@ -428,11 +457,19 @@ namespace GameCult.Eve.UnityScene { if (asset == null) throw new ArgumentNullException(nameof(asset)); if (assetType == null) throw new ArgumentNullException(nameof(assetType)); - EnsureAssetLoaded(asset.AssetRef); + EnsureAssetPrepared(asset.AssetRef); return _nativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) ? value : null; } + private void EnsureAssetPrepared(string assetRef) + { + EnsurePrepared(); + if (_assetSelections.ContainsKey(assetRef) && !_nativeAssets.ContainsKey(assetRef)) + throw new InvalidOperationException( + $"Provider asset '{assetRef}' was not materialized during asynchronous preparation."); + } + public bool TryResolveAssetMetadata( EveUnityPlayableWorldAssetBinding asset, out IReadOnlyDictionary metadata) @@ -460,15 +497,16 @@ namespace GameCult.Eve.UnityScene }); } - private void ResolveAdvertisement(bool forceRefresh = false) + private async Task ResolveAdvertisementAsync( + bool forceRefresh = false, + CancellationToken cancellationToken = default) { if (!forceRefresh && _advertisement != null && _advertisedSurface != null) return; - using var advertisements = _meshClient! - .LeaseCollectionAsync(_target) - .GetAwaiter() - .GetResult(); - _advertisement = advertisements.Handle.LatestAsync().GetAwaiter().GetResult().FirstOrDefault(document => + using var lease = await _meshClient! + .LeaseCollectionAsync(_target, cancellationToken); + var documents = await lease.Handle.LatestAsync(); + _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 => @@ -535,7 +573,7 @@ namespace GameCult.Eve.UnityScene EveEntitySoaViewDocument document, CultMeshBodyPublicationDocument publication) { - ResolveAdvertisement(); + EnsurePrepared(); if (!string.Equals(document.ProviderId, _advertisement!.ProviderId, StringComparison.Ordinal)) throw new UnauthorizedAccessException( $"Entity layout provider '{document.ProviderId}' does not match advertised Eve provider '{_advertisement.ProviderId}'."); @@ -820,12 +858,10 @@ namespace GameCult.Eve.UnityScene DateTimeOffset now) => publication.LivenessExpiresAtUnixMs > now.ToUnixTimeMilliseconds(); - private void RefreshSurface() + private async Task RefreshSurfaceAsync(CancellationToken cancellationToken) { - var surface = _meshClient! - .ReadAsync(_target, _advertisedSurface!.RecordRef) - .GetAwaiter() - .GetResult(); + var surface = await _meshClient! + .ReadAsync(_target, _advertisedSurface!.RecordRef, cancellationToken); PublishBaseSurface(surface); } @@ -861,6 +897,13 @@ namespace GameCult.Eve.UnityScene interaction.Ownership)), _advertisedSurface.RecordRef, surface.Version); + var lowered = new EveUnitySceneSurfaceLowerer() + .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); + _continuousCommandIds.Clear(); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.MovementCommand)) + _continuousCommandIds.Add(lowered.PlayableWorld.MovementCommand); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.LookCommand)) + _continuousCommandIds.Add(lowered.PlayableWorld.LookCommand); SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); } @@ -908,13 +951,13 @@ namespace GameCult.Eve.UnityScene component.Style); } - private void EnsureLiveDocuments() + private async Task EnsureLiveDocumentsAsync(CancellationToken cancellationToken) { if (_liveDocumentsReady) return; DisposeLiveDocuments(); try { - OpenLiveDocuments(); + await OpenLiveDocumentsAsync(cancellationToken); _liveDocumentsReady = true; } catch @@ -924,7 +967,7 @@ namespace GameCult.Eve.UnityScene } } - private void OpenLiveDocuments() + private async Task OpenLiveDocumentsAsync(CancellationToken cancellationToken) { var lowered = new EveUnitySceneSurfaceLowerer() .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); @@ -947,19 +990,23 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException( "The playable world advertises an entity-view pointer without its logical body id."); _bodyResolver ??= CreateBodyResolver(); - LeaseDocument(entityViewPointer, QueueEntityView); - LeaseCollection(publication => + await LeaseDocumentAsync(entityViewPointer, QueueEntityView, cancellationToken); + await LeaseCollectionAsync(publication => { if (string.Equals(publication.BodyId, entityBodyId, StringComparison.Ordinal)) QueueBodyPublication(publication); - }); + }, cancellationToken); } foreach (var fieldRef in fieldRefs) - LeaseDocument(fieldRef, fields => _liveDocuments.Enqueue(fields)); + await LeaseDocumentAsync( + fieldRef, + fields => _liveDocuments.Enqueue(fields), + cancellationToken); - LeaseDocument( + await LeaseDocumentAsync( _advertisedSurface!.RecordRef, surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(_advertisedSurface.RecordRef, surface)), + cancellationToken, publishInitial: false); var embeddedSlots = EnumerateEmbeddedDocuments(CurrentSurfaceDocument.SurfaceDocument.Surface.Root) .Where(slot => string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal)) @@ -969,29 +1016,33 @@ namespace GameCult.Eve.UnityScene .ToArray(); foreach (var slot in embeddedSlots) { - LeaseDocument( + await LeaseDocumentAsync( slot.DocumentId, - surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface))); + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface)), + cancellationToken); } var inputCapabilityRef = FindComponentProp( CurrentSurfaceDocument.SurfaceDocument.Surface.Root, "inputCapability"); if (!string.IsNullOrWhiteSpace(inputCapabilityRef)) { - LeaseDocument( + await LeaseDocumentAsync( inputCapabilityRef, - document => _liveDocuments.Enqueue(document)); + document => _liveDocuments.Enqueue(document), + cancellationToken); } var assetCatalogRef = RequireWorldInteraction().AssetManifestRecordRef; if (!string.IsNullOrWhiteSpace(assetCatalogRef)) { - LeaseDocument( + await LeaseDocumentAsync( assetCatalogRef, document => _liveDocuments.Enqueue(document), + cancellationToken, publishInitial: false); } - LeaseCollection( + await LeaseCollectionAsync( receipt => _liveDocuments.Enqueue(receipt), + cancellationToken, includeInitialSnapshot: false); } @@ -1004,29 +1055,32 @@ namespace GameCult.Eve.UnityScene _liveDocumentsReady = false; } - private void LeaseDocument( + private async Task LeaseDocumentAsync( string recordKey, Action publish, + CancellationToken cancellationToken, bool publishInitial = true) where TDocument : class { - var lease = _meshClient!.LeaseDocumentAsync(_target, recordKey) - .GetAwaiter().GetResult(); + var lease = await _meshClient!.LeaseDocumentAsync(_target, recordKey, cancellationToken); _documentLeases.Add(lease); - if (publishInitial) publish(lease.Handle.Latest()); + if (publishInitial) publish(await lease.Handle.LatestAsync()); _documentWatches.Add(lease.Handle.Watch(publish)); } - private void LeaseCollection( + private async Task LeaseCollectionAsync( Action publish, + CancellationToken cancellationToken, bool includeInitialSnapshot = true) where TDocument : class { - var lease = _meshClient!.LeaseCollectionAsync(_target, includeInitialSnapshot) - .GetAwaiter().GetResult(); + var lease = await _meshClient!.LeaseCollectionAsync( + _target, + includeInitialSnapshot, + cancellationToken); _documentLeases.Add(lease); if (includeInitialSnapshot) - foreach (var document in lease.Handle.LatestAsync().GetAwaiter().GetResult()) + foreach (var document in await lease.Handle.LatestAsync()) publish(document); _documentWatches.Add(lease.Handle.WatchChanges(change => { @@ -1061,16 +1115,17 @@ namespace GameCult.Eve.UnityScene Debug.Log($"EveUnity CultMesh hot state: {message}"); } - private void RefreshAssetCatalog() + private async Task RefreshAssetCatalogAsync(CancellationToken cancellationToken) { var interaction = RequireWorldInteraction(); if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) return; - var catalog = _meshClient! - .ReadAsync(_target, interaction.AssetManifestRecordRef) - .GetAwaiter() - .GetResult(); + var catalog = await _meshClient! + .ReadAsync( + _target, + interaction.AssetManifestRecordRef, + cancellationToken); PublishAssetCatalog(catalog); } @@ -1136,14 +1191,49 @@ namespace GameCult.Eve.UnityScene CurrentAssetCatalogVersion = catalog.Version; } - private void EnsureAssetLoaded(string assetRef) + private void QueueAssetCatalogUpdate(EveAssetCatalogDocument catalog) { - if (_nativeAssets.ContainsKey(assetRef) || !_assetSelections.TryGetValue(assetRef, out var selection)) - return; - EnsureBundleLoaded(selection.Variant!.Uri); + _pendingAssetCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + if (_assetCatalogUpdateRunning) return; + _assetCatalogUpdateRunning = true; + _ = DrainAssetCatalogUpdatesAsync(); } - private void EnsureBundleLoaded(string uri) + private async Task DrainAssetCatalogUpdatesAsync() + { + try + { + while (!_lifetime.IsCancellationRequested) + { + var catalog = _pendingAssetCatalog; + _pendingAssetCatalog = null; + if (catalog == null) break; + PublishAssetCatalog(catalog); + await PreloadAssetsAsync(_lifetime.Token); + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (Exception error) + { + _liveDocuments.Enqueue(error); + } + finally + { + _assetCatalogUpdateRunning = false; + if (_pendingAssetCatalog != null && !_lifetime.IsCancellationRequested) + QueueAssetCatalogUpdate(_pendingAssetCatalog); + } + } + + private async Task PreloadAssetsAsync(CancellationToken cancellationToken) + { + foreach (var uri in _bundleVariants.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray()) + await EnsureBundleLoadedAsync(uri, cancellationToken); + } + + private async Task EnsureBundleLoadedAsync(string uri, CancellationToken cancellationToken) { if (_loadedBundleUris.Contains(uri)) return; if (!_bundleVariants.TryGetValue(uri, out var variant)) @@ -1154,14 +1244,13 @@ namespace GameCult.Eve.UnityScene { if (variant.Metadata.TryGetValue("unity.bundleDependencyUris", out var dependencies)) foreach (var dependency in dependencies.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) - EnsureBundleLoaded(dependency); + await EnsureBundleLoadedAsync(dependency, cancellationToken); var groupElapsed = Stopwatch.StartNew(); - var descriptor = _meshClient!.ReadAsync(_target, uri) - .GetAwaiter() - .GetResult(); + var descriptor = await _meshClient! + .ReadAsync(_target, uri, cancellationToken); TraceStartup("asset-manifest", groupElapsed); - var bundle = LoadVerifiedBundle(descriptor, variant); + var bundle = await LoadVerifiedBundleAsync(descriptor, variant, cancellationToken); TraceStartup("asset-transfer-and-bundle-load", groupElapsed); if (bundle == null) throw new InvalidOperationException($"Unity could not load provider asset bundle '{uri}'."); @@ -1172,7 +1261,7 @@ namespace GameCult.Eve.UnityScene var bundleAssetName = ResolveBundleAssetName(bundleAssetNames, selection.Variant!.AssetKey); var value = bundleAssetName == null ? null - : LoadBundleAsset(bundle, bundleAssetName, selection.Asset.AssetKind); + : await LoadBundleAssetAsync(bundle, bundleAssetName, selection.Asset.AssetKind, cancellationToken); if (value == null) throw new InvalidOperationException( $"Provider asset '{selection.Asset.AssetRef}' advertises missing Unity bundle asset " + @@ -1196,11 +1285,17 @@ namespace GameCult.Eve.UnityScene } } - private static UnityEngine.Object? LoadBundleAsset(AssetBundle bundle, string assetName, string assetKind) + private static async Task LoadBundleAssetAsync( + AssetBundle bundle, + string assetName, + string assetKind, + CancellationToken cancellationToken) { - return string.Equals(assetKind, "sprite", StringComparison.Ordinal) - ? bundle.LoadAsset(assetName) - : bundle.LoadAsset(assetName); + var request = string.Equals(assetKind, "sprite", StringComparison.Ordinal) + ? bundle.LoadAssetAsync(assetName) + : bundle.LoadAssetAsync(assetName); + await AwaitUnityAsync(request, cancellationToken); + return request.asset; } private sealed class SelectedAsset @@ -1263,9 +1358,10 @@ namespace GameCult.Eve.UnityScene private CultMeshContentTransferService ContentTransfer() { - ResolveAdvertisement(); if (_contentTransfer != null) return _contentTransfer; + if (_advertisement == null) + throw new InvalidOperationException("The Eve provider advertisement must be prepared before content transfer."); if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId)) throw new InvalidOperationException($"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for content transport."); var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH"); @@ -1287,7 +1383,10 @@ namespace GameCult.Eve.UnityScene return _contentTransfer; } - private AssetBundle? LoadVerifiedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant) + private async Task LoadVerifiedBundleAsync( + CultMeshCdnArtifactManifest manifest, + EveAssetVariant variant, + CancellationToken cancellationToken) { var hash = NormalizeContentHash(variant.ContentHash); if (manifest.SizeBytes != variant.SizeBytes) @@ -1299,12 +1398,10 @@ namespace GameCult.Eve.UnityScene var now = DateTimeOffset.UtcNow; var network = NetworkArtifactDescriptor(manifest, now, TimeSpan.FromMinutes(5)); var elapsed = Stopwatch.StartNew(); - var mapped = ContentTransfer() - .FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5)) - .GetAwaiter() - .GetResult(); + var mapped = await ContentTransfer() + .FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5), cancellationToken); TraceStartup("content-materialization", elapsed); - var request = new CultMeshBodyValidationRequest + var validationRequest = new CultMeshBodyValidationRequest { BodyId = network.BodyId, SchemaId = network.SchemaId, @@ -1319,33 +1416,27 @@ namespace GameCult.Eve.UnityScene new ICultMeshBodyTransportAdapter[] { new CultMeshMappedBodyAdapter(_assetBodyMappings!), - new CultMeshNetworkBodyAdapter(_ => ReadVerifiedArtifactBytes(manifest)) + new CultMeshNetworkBodyAdapter(_ => File.ReadAllBytes(mapped.VerifiedPath)) }, descriptor => string.Equals(descriptor.BodyId, manifest.ArtifactId, StringComparison.Ordinal) && string.Equals(descriptor.SemanticHash, hash, StringComparison.Ordinal)); - var negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, request); + var negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, validationRequest); CurrentAssetBodyTransportKind = negotiated.SelectedTransport; _assetBodyLeases.Add(negotiated.Lease); - if (negotiated.SelectedTransport == CultMeshBodyTransportKind.SharedFileMapping) - { - var bundle = AssetBundle.LoadFromFile(mapped.VerifiedPath); - TraceStartup("asset-bundle-load-from-file", elapsed); - return bundle; - } - - if (negotiated.Lease.Descriptor.ByteSize > int.MaxValue) - throw new InvalidOperationException("Unity cannot lower a network asset body larger than one managed byte array."); - var bytes = new byte[checked((int)negotiated.Lease.Descriptor.ByteSize)]; - negotiated.Lease.CopyTo(0, bytes, 0, bytes.Length); - var loaded = AssetBundle.LoadFromMemory(bytes); - TraceStartup("asset-bundle-load-from-memory", elapsed); - return loaded; + var bundleRequest = AssetBundle.LoadFromFileAsync(mapped.VerifiedPath); + await AwaitUnityAsync(bundleRequest, cancellationToken); + TraceStartup("asset-bundle-load-from-file", elapsed); + return bundleRequest.assetBundle; } - private byte[] ReadVerifiedArtifactBytes(CultMeshCdnArtifactManifest manifest) + private static async Task AwaitUnityAsync(AsyncOperation operation, CancellationToken cancellationToken) { - var path = ContentTransfer().FetchAsync(manifest).GetAwaiter().GetResult(); - return File.ReadAllBytes(path); + while (!operation.isDone) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + cancellationToken.ThrowIfCancellationRequested(); } private static CultMeshBodyDescriptor NetworkArtifactDescriptor( @@ -1375,8 +1466,12 @@ namespace GameCult.Eve.UnityScene private void PublishReceipt(EveCommandReceiptDocument receipt) { - if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) - return; + lock (_pendingCommandIds) + { + if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) + return; + _pendingCommandIds.Remove(receipt.CommandId); + } CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, @@ -1398,10 +1493,6 @@ namespace GameCult.Eve.UnityScene receipt.Navigation.SurfaceId, receipt.Navigation.SurfaceKind, receipt.Navigation.RendezvousEndpoints))); - if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) - _pendingCommandIds.Remove(receipt.CommandId); } private EveWorldInteractionAdvertisement RequireWorldInteraction() 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 1e71df7..6f049bf 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -3,6 +3,7 @@ using System.IO; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Stopwatch = System.Diagnostics.Stopwatch; using GameCult.Eve.Surface; @@ -43,6 +44,7 @@ namespace GameCult.Eve.UnityScene private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; private Task? _preparation; + private CancellationTokenSource? _preparationLifetime; private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); private readonly ConcurrentQueue _pendingFields = new ConcurrentQueue(); @@ -109,7 +111,11 @@ namespace GameCult.Eve.UnityScene if (_bridge != null) return Task.CompletedTask; if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted) - _preparation = PrepareTransportAsync(); + { + _preparationLifetime?.Dispose(); + _preparationLifetime = new CancellationTokenSource(); + _preparation = PrepareTransportAsync(_preparationLifetime.Token); + } return _preparation; } @@ -184,6 +190,7 @@ namespace GameCult.Eve.UnityScene private void ReleaseTransport() { + _preparationLifetime?.Cancel(); if (_bridge != null) { _bridge.DocumentAvailable -= ForwardDocument; @@ -196,6 +203,8 @@ namespace GameCult.Eve.UnityScene _bridge = null; _transport = null; _preparation = null; + _preparationLifetime?.Dispose(); + _preparationLifetime = null; Selection = null; } @@ -228,7 +237,7 @@ namespace GameCult.Eve.UnityScene } } - private async Task PrepareTransportAsync() + private async Task PrepareTransportAsync(CancellationToken cancellationToken) { var elapsed = Stopwatch.StartNew(); if (_transport != null) @@ -241,13 +250,14 @@ namespace GameCult.Eve.UnityScene providerFilter, surfaceFilter, surfaceKind, - verseFilter); + verseFilter, + cancellationToken); TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); elapsed.Restart(); var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") : cacheDirectory; - _transport = new EveUnityCultMeshLiveProviderTransport( + var transport = new EveUnityCultMeshLiveProviderTransport( resolvedCachePath, Selection.RendezvousEndpoint, Selection.VerseId, @@ -255,6 +265,17 @@ namespace GameCult.Eve.UnityScene Selection.ProviderId, Selection.SurfaceId, runtimeId); + try + { + await transport.PrepareAsync(cancellationToken); + } + catch + { + transport.Dispose(); + throw; + } + cancellationToken.ThrowIfCancellationRequested(); + _transport = transport; _transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); _transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields); _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 74d3040..6628f28 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; @@ -38,21 +39,13 @@ namespace GameCult.Eve.UnityScene public sealed class EveUnityCultMeshProviderDiscovery { - 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 = "", string surfaceId = "", string surfaceKind = "interactive-world", - string verseId = "") + string verseId = "", + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); @@ -67,6 +60,7 @@ namespace GameCult.Eve.UnityScene VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId }, TransportVersion = "cultmesh.v0" }); + cancellationToken.ThrowIfCancellationRequested(); } catch (Exception error) { @@ -104,7 +98,7 @@ namespace GameCult.Eve.UnityScene { var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId); using var advertisementsLease = await mesh - .LeaseCollectionAsync(target) + .LeaseCollectionAsync(target, cancellationToken) .ConfigureAwait(false); var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false); observed.AddRange(advertisements.Select(document => diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index 5f1d489..1061353 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -5,8 +5,11 @@ using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.IO; +using System.Threading; +using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Eve.UnityScene; +using GameCult.Mesh; using NUnit.Framework; using UnityEngine; @@ -14,6 +17,79 @@ namespace GameCult.Eve.UnityScene.Tests { public sealed class EveUnityCultMeshAssetLookupTests { + [Test] + public void ConnectCannotPerformRemoteWorkBeforeAsynchronousPreparation() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + + var elapsed = Stopwatch.StartNew(); + var failure = Assert.Throws(() => transport.Connect()); + + StringAssert.Contains("Await PrepareAsync", failure!.Message); + Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100))); + } + + [Test] + public async Task CommandOutboxReturnsWhileDeliveryIsBlockedAndDeliversOneShotExactlyOnce() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sends = 0; + using var outbox = new EveUnityCultMeshCommandOutbox( + async (_, cancellationToken) => + { + Interlocked.Increment(ref sends); + entered.TrySetResult(true); + await AwaitOrCancel(release.Task, cancellationToken); + }, + _ => null); + + var elapsed = Stopwatch.StartNew(); + outbox.Enqueue(Request("one-shot", "dock")); + Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100))); + await RequireCompletion(entered.Task); + Assert.That(outbox.PendingCount, Is.EqualTo(1)); + + release.TrySetResult(true); + await WaitUntilAsync(() => outbox.PendingCount == 0); + Assert.That(sends, Is.EqualTo(1)); + } + + [Test] + public async Task CommandOutboxCoalescesBlockedContinuousInputToTheLatestValue() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sent = new List(); + using var outbox = new EveUnityCultMeshCommandOutbox( + async (request, cancellationToken) => + { + lock (sent) sent.Add(request.CommandId); + entered.TrySetResult(true); + await AwaitOrCancel(release.Task, cancellationToken); + }, + request => request.PayloadFields["commandId"] + "\u001f" + request.PayloadFields["entityId"]); + + outbox.Enqueue(Request("move-0", "move")); + await RequireCompletion(entered.Task); + for (var index = 1; index <= 1000; index++) + outbox.Enqueue(Request("move-" + index, "move")); + + Assert.That(outbox.PendingCount, Is.LessThanOrEqualTo(2)); + release.TrySetResult(true); + await WaitUntilAsync(() => outbox.PendingCount == 0); + lock (sent) + { + CollectionAssert.AreEqual(new[] { "move-0", "move-1000" }, sent); + } + } + [Test] public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute() { @@ -31,6 +107,41 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null); } + private static EveSurfaceCommandRequest Request(string id, string command) => new EveSurfaceCommandRequest( + "test.provider", + "test.surface", + CultMesh.OperationInvocation("test.command", idempotencyKey: id), + CultMesh.OperationPayload(new Dictionary + { + ["commandId"] = command, + ["entityId"] = "ship" + }), + DateTimeOffset.UtcNow, + "test-client"); + + private static async Task AwaitOrCancel(Task task, CancellationToken cancellationToken) + { + var cancelled = Task.Delay(Timeout.Infinite, cancellationToken); + if (await Task.WhenAny(task, cancelled) != task) + cancellationToken.ThrowIfCancellationRequested(); + await task; + } + + private static async Task RequireCompletion(Task task) + { + if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))) != task) + Assert.Fail("Timed out waiting for asynchronous command delivery."); + await task; + } + + private static async Task WaitUntilAsync(Func predicate) + { + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(2); + while (!predicate() && DateTimeOffset.UtcNow < deadline) + await Task.Delay(10); + Assert.That(predicate(), Is.True, "Timed out waiting for the command outbox to drain."); + } + [Test] public void LiveTransportOwnsOneCultMeshClientAndNoDirectSnapshotReplica() { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index b2c2a36..49bbacd 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.106", + "version": "0.3.107", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { From 82b48cd089b55e47f5112b2801f2eb48c1c002dc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 10:48:06 +0200 Subject: [PATCH 14/45] Test EveUnity through isolated package consumers --- .gitignore | 3 + TestProject/Packages/packages-lock.json | 8 +-- .../org.gamecult.eve.unity-scene/package.json | 4 +- scripts/run-package-tests.ps1 | 69 +++++++++++++------ scripts/run-release-consumer-tests.ps1 | 7 +- 5 files changed, 64 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 2c9c34e..975deb4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ TestProject/UserSettings/ obj/ *.csproj *.sln +packages/org.gamecult.eve.surface/bin~/ +packages/org.gamecult.eve.surface/obj~/ +packages/org.gamecult.eve.surface/Directory.Build.props.meta diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index bbda013..3f117a0 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -145,10 +145,10 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.45", + "org.gamecult.cultlib": "1.0.47", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.0", - "org.gamecult.eve.unity-uitoolkit": "0.1.3", + "org.gamecult.eve.surface": "0.3.2", + "org.gamecult.eve.unity-uitoolkit": "0.1.4", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" @@ -159,7 +159,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.eve.surface": "0.3.0" + "org.gamecult.eve.surface": "0.3.2" } }, "com.unity.modules.assetbundle": { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 49bbacd..41e1b2e 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,11 +1,11 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.107", + "version": "0.3.108", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.46", + "org.gamecult.cultlib": "1.0.47", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.2", "org.gamecult.eve.unity-uitoolkit": "0.1.4", diff --git a/scripts/run-package-tests.ps1 b/scripts/run-package-tests.ps1 index 2c9b78f..acd058d 100644 --- a/scripts/run-package-tests.ps1 +++ b/scripts/run-package-tests.ps1 @@ -1,26 +1,35 @@ param( [string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe", - [string] $CultLibRoot = "E:\Projects\CultLib", + [string] $CultLibRoot = "", + [string] $EveRoot = "", + [string] $EvePluginsRoot = "", [string] $ProjectRoot = "TestProject", [string] $OutputRoot = "artifacts\package-tests" ) $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot +$workspaceRoot = Split-Path -Parent $repoRoot +if ([string]::IsNullOrWhiteSpace($CultLibRoot)) { $CultLibRoot = Join-Path $workspaceRoot "CultLib" } +if ([string]::IsNullOrWhiteSpace($EveRoot)) { $EveRoot = Join-Path $workspaceRoot "Eve" } +if ([string]::IsNullOrWhiteSpace($EvePluginsRoot)) { $EvePluginsRoot = Join-Path $workspaceRoot "EvePlugins" } $projectPath = if ([IO.Path]::IsPathRooted($ProjectRoot)) { $ProjectRoot } else { Join-Path $repoRoot $ProjectRoot } $output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot } $cultLibBuilder = Join-Path $CultLibRoot "scripts\build-unity-package.ps1" $obsoleteSurfacePackage = Join-Path $repoRoot "packages\org.gamecult.eve.surface" -$testManifestPath = Join-Path $projectPath "Packages\manifest.json" -if (Test-Path -LiteralPath $obsoleteSurfacePackage) { +$obsoleteSurfaceSource = @("package.json", "Runtime", "Editor") | + ForEach-Object { Join-Path $obsoleteSurfacePackage $_ } | + Where-Object { Test-Path -LiteralPath $_ } +if ($obsoleteSurfaceSource.Count -gt 0) { throw "EveUnity must consume the Eve-owned surface package; duplicate source exists at $obsoleteSurfacePackage" } -$testManifest = Get-Content -LiteralPath $testManifestPath -Raw | ConvertFrom-Json -$surfaceDependency = [string]$testManifest.dependencies."org.gamecult.eve.surface" -if ($surfaceDependency -notmatch '(?i)Eve/packages/org\.gamecult\.eve\.surface$') { - throw "EveUnity test consumer must resolve org.gamecult.eve.surface from Eve; found '$surfaceDependency'." -} -foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) { +foreach ($required in @( + $UnityExe, + $projectPath, + $cultLibBuilder, + (Join-Path $EveRoot "packages\org.gamecult.eve.surface\package.json"), + (Join-Path $EvePluginsRoot "plugins\eve-plugin-fields\unity\org.gamecult.eve.plugin-fields\package.json") +)) { if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" } } @@ -28,23 +37,43 @@ powershell -ExecutionPolicy Bypass -File $cultLibBuilder if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" } $builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib" -$stagedDependenciesRoot = Join-Path $repoRoot "artifacts\test-dependencies" -$stagedCultLibPackage = Join-Path $stagedDependenciesRoot "org.gamecult.cultlib" if (-not (Test-Path -LiteralPath $builtCultLibPackage)) { throw "CultLib Unity package build did not produce: $builtCultLibPackage" } -New-Item -ItemType Directory -Force -Path $stagedDependenciesRoot | Out-Null -if (Test-Path -LiteralPath $stagedCultLibPackage) { - [IO.Directory]::Delete($stagedCultLibPackage, $true) -} -Copy-Item -LiteralPath $builtCultLibPackage -Destination $stagedCultLibPackage -Recurse $runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss") -New-Item -ItemType Directory -Force -Path $runRoot | Out-Null +$consumerPath = Join-Path $runRoot "consumer" +$consumerPackagesPath = Join-Path $consumerPath "Packages" +$stagedDependenciesRoot = Join-Path $runRoot "dependencies" +New-Item -ItemType Directory -Force -Path $consumerPath, $consumerPackagesPath, $stagedDependenciesRoot | Out-Null +Copy-Item -LiteralPath (Join-Path $projectPath "Assets") -Destination (Join-Path $consumerPath "Assets") -Recurse +Copy-Item -LiteralPath (Join-Path $projectPath "ProjectSettings") -Destination (Join-Path $consumerPath "ProjectSettings") -Recurse + +$dependencySources = [ordered]@{ + "org.gamecult.cultlib" = $builtCultLibPackage + "org.gamecult.eve.plugin-fields" = Join-Path $EvePluginsRoot "plugins\eve-plugin-fields\unity\org.gamecult.eve.plugin-fields" + "org.gamecult.eve.surface" = Join-Path $EveRoot "packages\org.gamecult.eve.surface" + "org.gamecult.eve.unity-scene" = Join-Path $repoRoot "packages\org.gamecult.eve.unity-scene" + "org.gamecult.eve.unity-uitoolkit" = Join-Path $repoRoot "packages\org.gamecult.eve.unity-uitoolkit" +} +foreach ($entry in $dependencySources.GetEnumerator()) { + Copy-Item -LiteralPath $entry.Value -Destination (Join-Path $stagedDependenciesRoot $entry.Key) -Recurse +} + +$manifest = Get-Content -LiteralPath (Join-Path $projectPath "Packages\manifest.json") -Raw | ConvertFrom-Json +foreach ($packageId in $dependencySources.Keys) { + $manifest.dependencies.$packageId = "file:../../dependencies/$packageId" +} +$manifestJson = $manifest | ConvertTo-Json -Depth 20 +[IO.File]::WriteAllText( + (Join-Path $consumerPackagesPath "manifest.json"), + $manifestJson, + [Text.UTF8Encoding]::new($false)) + $resultsPath = Join-Path $runRoot "unity-editmode-results.xml" $logPath = Join-Path $runRoot "unity-editmode.log" $arguments = @( - "-batchmode", "-projectPath", $projectPath, + "-batchmode", "-projectPath", $consumerPath, "-runTests", "-testPlatform", "EditMode", "-assemblyNames", "GameCult.Eve.UnityScene.Tests", "-testResults", $resultsPath, "-logFile", $logPath @@ -72,5 +101,5 @@ if ($null -eq $run -or [int]$run.total -eq 0 -or [int]$run.failed -gt 0) { throw "EveUnity package tests did not pass: $resultsPath" } Write-Host "EveUnity generic package tests: $($run.passed) passed, $($run.total) total" -Write-Host "Consumer: $projectPath" -Write-Host "CultLib package: $stagedCultLibPackage" +Write-Host "Isolated consumer: $consumerPath" +Write-Host "Staged dependencies: $stagedDependenciesRoot" diff --git a/scripts/run-release-consumer-tests.ps1 b/scripts/run-release-consumer-tests.ps1 index af0dd6d..aa57c7f 100644 --- a/scripts/run-release-consumer-tests.ps1 +++ b/scripts/run-release-consumer-tests.ps1 @@ -15,7 +15,12 @@ $arguments = @( "-testResults", $results, "-logFile", $logPath ) -$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -WorkingDirectory $repoRoot -Wait -PassThru -WindowStyle Hidden +$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden +if (-not $process.WaitForExit([int][TimeSpan]::FromMinutes(10).TotalMilliseconds)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + if (Test-Path $logPath) { Get-Content -LiteralPath $logPath -Tail 120 } + throw "Released EveUnity package consumer tests timed out after 10 minutes (Unity PID $($process.Id))." +} if ($process.ExitCode -ne 0) { if (Test-Path $logPath) { Get-Content -LiteralPath $logPath -Tail 120 } throw "Released EveUnity package consumer tests failed with exit code $($process.ExitCode)" From cec589d94db579a2abcf78cd35398ef186ed917c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 10:50:24 +0200 Subject: [PATCH 15/45] Pin a reproducible EveUnity git consumer --- ReleaseConsumerProject/Packages/manifest.json | 8 +++---- .../Packages/packages-lock.json | 24 +++++++++---------- scripts/run-release-consumer-tests.ps1 | 10 +++++++- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/ReleaseConsumerProject/Packages/manifest.json b/ReleaseConsumerProject/Packages/manifest.json index 07406db..683f069 100644 --- a/ReleaseConsumerProject/Packages/manifest.json +++ b/ReleaseConsumerProject/Packages/manifest.json @@ -6,11 +6,11 @@ "com.unity.modules.physics": "1.0.0", "com.unity.modules.uielements": "1.0.0", "com.unity.render-pipelines.universal": "17.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#21163d83fa2670dbcc9cdd57c6c1776b33a91d62", "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", - "org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0", - "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9", - "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9" + "org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#e7b09f755de2fa54e2c5936331e3a03839a13cd1", + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#82b48cd089b55e47f5112b2801f2eb48c1c002dc", + "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#82b48cd089b55e47f5112b2801f2eb48c1c002dc" }, "testables": [ "org.gamecult.eve.unity-scene", diff --git a/ReleaseConsumerProject/Packages/packages-lock.json b/ReleaseConsumerProject/Packages/packages-lock.json index 4510f6a..f155b89 100644 --- a/ReleaseConsumerProject/Packages/packages-lock.json +++ b/ReleaseConsumerProject/Packages/packages-lock.json @@ -119,11 +119,11 @@ } }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#21163d83fa2670dbcc9cdd57c6c1776b33a91d62", "depth": 0, "source": "git", "dependencies": {}, - "hash": "03840d0430bc727f1322861a1ab46b396eaca860" + "hash": "21163d83fa2670dbcc9cdd57c6c1776b33a91d62" }, "org.gamecult.eve.plugin-fields": { "version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", @@ -135,37 +135,37 @@ "hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0" }, "org.gamecult.eve.surface": { - "version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0", + "version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#e7b09f755de2fa54e2c5936331e3a03839a13cd1", "depth": 0, "source": "git", "dependencies": { "org.gamecult.cultlib": "1.0.45" }, - "hash": "fe0dddf28267decbb416325a3d0b2c62432825c2" + "hash": "e7b09f755de2fa54e2c5936331e3a03839a13cd1" }, "org.gamecult.eve.unity-scene": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#82b48cd089b55e47f5112b2801f2eb48c1c002dc", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.45", + "org.gamecult.cultlib": "1.0.47", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.0", - "org.gamecult.eve.unity-uitoolkit": "0.1.3", + "org.gamecult.eve.surface": "0.3.2", + "org.gamecult.eve.unity-uitoolkit": "0.1.4", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" }, - "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" + "hash": "82b48cd089b55e47f5112b2801f2eb48c1c002dc" }, "org.gamecult.eve.unity-uitoolkit": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#82b48cd089b55e47f5112b2801f2eb48c1c002dc", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.eve.surface": "0.3.0" + "org.gamecult.eve.surface": "0.3.2" }, - "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" + "hash": "82b48cd089b55e47f5112b2801f2eb48c1c002dc" }, "com.unity.modules.assetbundle": { "version": "1.0.0", diff --git a/scripts/run-release-consumer-tests.ps1 b/scripts/run-release-consumer-tests.ps1 index aa57c7f..c726a30 100644 --- a/scripts/run-release-consumer-tests.ps1 +++ b/scripts/run-release-consumer-tests.ps1 @@ -5,9 +5,16 @@ param( $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot -$projectPath = "ReleaseConsumerProject" +$sourceProjectPath = Join-Path $repoRoot "ReleaseConsumerProject" $results = if ([IO.Path]::IsPathRooted($ResultsPath)) { $ResultsPath } else { Join-Path $repoRoot $ResultsPath } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $results) | Out-Null +$runRoot = Join-Path (Split-Path -Parent $results) ("release-consumer-" + (Get-Date -Format "yyyyMMddTHHmmss")) +$projectPath = Join-Path $runRoot "consumer" +New-Item -ItemType Directory -Force -Path (Join-Path $projectPath "Packages") | Out-Null +Copy-Item -LiteralPath (Join-Path $sourceProjectPath "Assets") -Destination (Join-Path $projectPath "Assets") -Recurse +Copy-Item -LiteralPath (Join-Path $sourceProjectPath "ProjectSettings") -Destination (Join-Path $projectPath "ProjectSettings") -Recurse +Copy-Item -LiteralPath (Join-Path $sourceProjectPath "Packages\manifest.json") ` + -Destination (Join-Path $projectPath "Packages\manifest.json") $logPath = [IO.Path]::ChangeExtension($results, ".log") $arguments = @( "-batchmode", "-projectPath", $projectPath, @@ -31,3 +38,4 @@ if (-not (Test-Path $results)) { throw "Unity did not produce release consumer t $result = $report.'test-run'.result if ($result -ne "Passed") { throw "Released EveUnity package consumer result was '$result'." } Write-Host "Released EveUnity package consumer passed: $results" +Write-Host "Isolated git consumer: $projectPath" From 1354dac447b45687b568ff675dab25be49e1f962 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 17:47:34 +0200 Subject: [PATCH 16/45] Keep Unity commands pending until provider receipts --- docs/authority-map.md | 5 +- .../org.gamecult.eve.unity-scene/README.md | 6 +- .../CultMesh/EveUnityCultMeshCommandOutbox.cs | 241 +++++++++++++----- .../EveUnityCultMeshLiveProviderTransport.cs | 1 + .../EveUnityCultMeshAssetLookupTests.cs | 69 ++++- .../org.gamecult.eve.unity-scene/package.json | 2 +- scripts/run-package-tests.ps1 | 12 +- 7 files changed, 258 insertions(+), 78 deletions(-) diff --git a/docs/authority-map.md b/docs/authority-map.md index 7a339b4..3be5f6c 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -79,7 +79,10 @@ submissions share the same stable-identity `CultMeshClient` owner. Remote bootstrap and asset materialization complete through `PrepareAsync` before the scene is mounted. `Connect`, `Mount`, input, refresh, and disposal cannot synchronously wait on CultMesh. A bounded command outbox owns delivery; -only unsent continuous movement/look values may be replaced by a newer value. +continuous movement/look values may be replaced by a newer value, but transport +send completion cannot retire any command. Only the provider's canonical +receipt retires its idempotency key. Unacknowledged work is retried fairly; it +cannot become client-authored success or starve unrelated commands. Viewport-sized shader inputs are likewise presentation state. A provider may relate a logical vector port to a logical native texture port through diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index e074c87..c54381c 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -28,7 +28,11 @@ It owns discovery, initial document reads, content transfer, asynchronous asset bundle loading, and live lease installation. Once preparation succeeds, `Mount()` and `Connect()` perform no remote work. Input submission only enters a bounded asynchronous outbox: one-shot commands retain their idempotency keys, -while unsent movement and look values coalesce to the latest value per entity. +while movement and look values coalesce to the latest value per entity. A +successful send is not completion: the command remains pending and retries with +the same idempotency key until its canonical provider receipt arrives. Failed +or unacknowledged commands rotate behind other due work, so one poison command +cannot starve the outbox. Live entity, field, and receipt polling likewise remains non-blocking. Unity's main thread applies completed typed documents and assets; it never waits for a CultMesh task, keeping Play, Pause, input, inspection, and rendering responsive diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs index 6a93b30..f8c89fa 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs @@ -10,14 +10,25 @@ using GameCult.Eve.Surface; namespace GameCult.Eve.UnityScene { /// - /// Owns asynchronous command delivery for the CultMesh Unity transport. - /// The Unity input path only enqueues. One-shot commands retain their - /// idempotency key; continuous controls retain only the newest unsent value. + /// Owns receipt-backed asynchronous command delivery for the CultMesh Unity + /// transport. A successful transport send only moves a command into + /// AwaitingCanonicalReceipt. The provider receipt is the sole completion + /// signal. Retries retain the original idempotency key, and one failed + /// command cannot monopolize delivery of the rest of the bounded outbox. /// internal sealed class EveUnityCultMeshCommandOutbox : IDisposable { + internal enum DeliveryState + { + Queued, + Sending, + AwaitingCanonicalReceipt + } + private readonly object _gate = new object(); - private readonly Queue _commands = new Queue(); + private readonly List _items = new List(); + private readonly Dictionary _byCommandId = + new Dictionary(StringComparer.Ordinal); private readonly Dictionary _continuous = new Dictionary(StringComparer.Ordinal); private readonly Func _send; @@ -25,18 +36,24 @@ namespace GameCult.Eve.UnityScene private readonly Action _discard; private readonly int _commandCapacity; private readonly int _continuousCapacity; - private readonly SemaphoreSlim _available = new SemaphoreSlim(0); + private readonly TimeSpan _retryDelay; + private readonly TimeSpan _maximumRetryDelay; + private readonly TimeSpan _sendAttemptTimeout; + private readonly SemaphoreSlim _changed = new SemaphoreSlim(0); private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); private readonly Task _worker; private bool _disposed; - private int _inFlight; + private long _sequence; public EveUnityCultMeshCommandOutbox( Func send, Func continuousKey, Action? discard = null, int commandCapacity = 256, - int continuousCapacity = 32) + int continuousCapacity = 32, + TimeSpan? retryDelay = null, + TimeSpan? maximumRetryDelay = null, + TimeSpan? sendAttemptTimeout = null) { _send = send ?? throw new ArgumentNullException(nameof(send)); _continuousKey = continuousKey ?? throw new ArgumentNullException(nameof(continuousKey)); @@ -45,15 +62,24 @@ namespace GameCult.Eve.UnityScene if (continuousCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(continuousCapacity)); _commandCapacity = commandCapacity; _continuousCapacity = continuousCapacity; + _retryDelay = retryDelay ?? TimeSpan.FromMilliseconds(100); + _maximumRetryDelay = maximumRetryDelay ?? TimeSpan.FromSeconds(2); + _sendAttemptTimeout = sendAttemptTimeout ?? TimeSpan.FromSeconds(2); + if (_retryDelay <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(retryDelay)); + if (_maximumRetryDelay < _retryDelay) throw new ArgumentOutOfRangeException(nameof(maximumRetryDelay)); + if (_sendAttemptTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(sendAttemptTimeout)); _worker = Task.Run(DeliverAsync); } internal int PendingCount { - get - { - lock (_gate) return _commands.Count + _continuous.Count + _inFlight; - } + get { lock (_gate) return _items.Count; } + } + + internal DeliveryState? StateOf(string commandId) + { + lock (_gate) + return _byCommandId.TryGetValue(commandId, out var item) ? item.State : null; } public void Enqueue(EveSurfaceCommandRequest request) @@ -63,37 +89,52 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException("Eve command invocations require an idempotency key."); string? discarded = null; - var signal = false; lock (_gate) { if (_disposed) throw new ObjectDisposedException(nameof(EveUnityCultMeshCommandOutbox)); + if (_byCommandId.ContainsKey(request.CommandId)) + throw new InvalidOperationException($"Eve command '{request.CommandId}' is already pending."); + var key = _continuousKey(request); - var item = new OutboxItem(request, string.IsNullOrWhiteSpace(key) ? null : key); - if (string.IsNullOrWhiteSpace(key)) + key = string.IsNullOrWhiteSpace(key) ? null : key; + if (key == null) { - if (_commands.Count >= _commandCapacity) + if (_items.Count(item => item.ContinuousKey == null) >= _commandCapacity) throw new InvalidOperationException( $"The EveUnity command outbox reached its {_commandCapacity}-command bound."); - _commands.Enqueue(item); - signal = true; } else if (_continuous.TryGetValue(key, out var previous)) { - _continuous[key] = item; + RemoveLocked(previous); discarded = previous.Request.CommandId; } - else + else if (_continuous.Count >= _continuousCapacity) { - if (_continuous.Count >= _continuousCapacity) - throw new InvalidOperationException( - $"The EveUnity continuous-control outbox reached its {_continuousCapacity}-binding bound."); - _continuous.Add(key, item); - signal = true; + throw new InvalidOperationException( + $"The EveUnity continuous-control outbox reached its {_continuousCapacity}-binding bound."); } + + var item = new OutboxItem(request, key, ++_sequence); + _items.Add(item); + _byCommandId.Add(request.CommandId, item); + if (key != null) _continuous[key] = item; } - if (!string.IsNullOrWhiteSpace(discarded)) _discard(discarded); - if (signal) _available.Release(); + if (discarded != null) _discard(discarded); + _changed.Release(); + } + + /// Completes a command only when its canonical provider receipt arrives. + public bool Acknowledge(string commandId) + { + if (string.IsNullOrWhiteSpace(commandId)) return false; + lock (_gate) + { + if (!_byCommandId.TryGetValue(commandId, out var item)) return false; + RemoveLocked(item); + } + _changed.Release(); + return true; } public void Dispose() @@ -103,13 +144,14 @@ namespace GameCult.Eve.UnityScene { if (_disposed) return; _disposed = true; - discarded = _commands.Concat(_continuous.Values).ToArray(); - _commands.Clear(); + discarded = _items.ToArray(); + _items.Clear(); + _byCommandId.Clear(); _continuous.Clear(); } foreach (var item in discarded) _discard(item.Request.CommandId); _lifetime.Cancel(); - _available.Release(); + _changed.Release(); } private async Task DeliverAsync() @@ -119,35 +161,13 @@ namespace GameCult.Eve.UnityScene { while (true) { - await _available.WaitAsync(cancellationToken).ConfigureAwait(false); - var item = TakeNext(); - if (item == null) continue; - try + var selection = TakeNext(); + if (selection.Item == null) { - var retryDelay = 50; - while (true) - { - try - { - await _send(item.Request, cancellationToken).ConfigureAwait(false); - break; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - if (WasSuperseded(item)) break; - await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); - retryDelay = Math.Min(retryDelay * 2, 2000); - } - } - } - finally - { - lock (_gate) _inFlight--; + await WaitForChangeOrDueAsync(selection.Delay, cancellationToken).ConfigureAwait(false); + continue; } + await DeliverOnceAsync(selection.Item, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -155,39 +175,122 @@ namespace GameCult.Eve.UnityScene } } - private OutboxItem? TakeNext() + private async Task DeliverOnceAsync(OutboxItem item, CancellationToken cancellationToken) + { + var sent = false; + using (var attempt = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + attempt.CancelAfter(_sendAttemptTimeout); + try + { + await _send(item.Request, attempt.Token).ConfigureAwait(false); + sent = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // The same idempotency key is retried after other due items. + } + } + + lock (_gate) + { + if (!_byCommandId.TryGetValue(item.Request.CommandId, out var current) || + !ReferenceEquals(item, current)) + return; + item.Attempts++; + item.State = sent ? DeliveryState.AwaitingCanonicalReceipt : DeliveryState.Queued; + item.NextAttemptAt = DateTimeOffset.UtcNow + RetryDelay(item.Attempts); + item.Sequence = ++_sequence; + } + _changed.Release(); + } + + private Selection TakeNext() { lock (_gate) { - if (_commands.Count > 0) + var now = DateTimeOffset.UtcNow; + var item = _items + .Where(candidate => candidate.State != DeliveryState.Sending && candidate.NextAttemptAt <= now) + .OrderBy(candidate => candidate.Sequence) + .FirstOrDefault(); + if (item != null) { - _inFlight++; - return _commands.Dequeue(); + item.State = DeliveryState.Sending; + return new Selection(item, null); } - if (_continuous.Count == 0) return null; - var pair = _continuous.First(); - _continuous.Remove(pair.Key); - _inFlight++; - return pair.Value; + var next = _items + .Where(candidate => candidate.State != DeliveryState.Sending) + .Select(candidate => (DateTimeOffset?)candidate.NextAttemptAt) + .OrderBy(value => value) + .FirstOrDefault(); + return new Selection(null, next.HasValue ? Max(TimeSpan.Zero, next.Value - now) : null); } } - private bool WasSuperseded(OutboxItem item) + private async Task WaitForChangeOrDueAsync(TimeSpan? delay, CancellationToken cancellationToken) { - if (item.ContinuousKey == null) return false; - lock (_gate) return _continuous.ContainsKey(item.ContinuousKey); + if (!delay.HasValue) + { + await _changed.WaitAsync(cancellationToken).ConfigureAwait(false); + return; + } + var milliseconds = (int)Math.Min(int.MaxValue, Math.Max(1, delay.Value.TotalMilliseconds)); + await _changed.WaitAsync(milliseconds, cancellationToken).ConfigureAwait(false); } + private TimeSpan RetryDelay(int attempts) + { + var multiplier = Math.Pow(2, Math.Min(attempts - 1, 10)); + return TimeSpan.FromMilliseconds(Math.Min( + _maximumRetryDelay.TotalMilliseconds, + _retryDelay.TotalMilliseconds * multiplier)); + } + + private void RemoveLocked(OutboxItem item) + { + _items.Remove(item); + _byCommandId.Remove(item.Request.CommandId); + if (item.ContinuousKey != null && + _continuous.TryGetValue(item.ContinuousKey, out var current) && + ReferenceEquals(item, current)) + _continuous.Remove(item.ContinuousKey); + } + + private static TimeSpan Max(TimeSpan left, TimeSpan right) => left >= right ? left : right; + private sealed class OutboxItem { - public OutboxItem(EveSurfaceCommandRequest request, string? continuousKey) + public OutboxItem(EveSurfaceCommandRequest request, string? continuousKey, long sequence) { Request = request; ContinuousKey = continuousKey; + Sequence = sequence; + NextAttemptAt = DateTimeOffset.MinValue; } public EveSurfaceCommandRequest Request { get; } public string? ContinuousKey { get; } + public DeliveryState State { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } + public int Attempts { get; set; } + public long Sequence { get; set; } + } + + private readonly struct Selection + { + public Selection(OutboxItem? item, TimeSpan? delay) + { + Item = item; + Delay = delay; + } + + public OutboxItem? Item { get; } + public TimeSpan? Delay { get; } } } } 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 31922e8..0ea02b6 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -1472,6 +1472,7 @@ namespace GameCult.Eve.UnityScene return; _pendingCommandIds.Remove(receipt.CommandId); } + _commandOutbox?.Acknowledge(receipt.CommandId); CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index 1061353..8e70be8 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -36,7 +36,7 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public async Task CommandOutboxReturnsWhileDeliveryIsBlockedAndDeliversOneShotExactlyOnce() + public async Task CommandOutboxCompletesOnlyAfterCanonicalReceipt() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -48,7 +48,8 @@ namespace GameCult.Eve.UnityScene.Tests entered.TrySetResult(true); await AwaitOrCancel(release.Task, cancellationToken); }, - _ => null); + _ => null, + retryDelay: TimeSpan.FromMilliseconds(250)); var elapsed = Stopwatch.StartNew(); outbox.Enqueue(Request("one-shot", "dock")); @@ -57,8 +58,13 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(outbox.PendingCount, Is.EqualTo(1)); release.TrySetResult(true); - await WaitUntilAsync(() => outbox.PendingCount == 0); + await WaitUntilAsync(() => outbox.StateOf("one-shot") == + EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt); + Assert.That(outbox.PendingCount, Is.EqualTo(1)); Assert.That(sends, Is.EqualTo(1)); + Assert.That(outbox.Acknowledge("one-shot"), Is.True); + Assert.That(outbox.Acknowledge("one-shot"), Is.False); + await WaitUntilAsync(() => outbox.PendingCount == 0); } [Test] @@ -74,7 +80,8 @@ namespace GameCult.Eve.UnityScene.Tests entered.TrySetResult(true); await AwaitOrCancel(release.Task, cancellationToken); }, - request => request.PayloadFields["commandId"] + "\u001f" + request.PayloadFields["entityId"]); + request => request.PayloadFields["commandId"] + "\u001f" + request.PayloadFields["entityId"], + retryDelay: TimeSpan.FromMilliseconds(250)); outbox.Enqueue(Request("move-0", "move")); await RequireCompletion(entered.Task); @@ -83,6 +90,9 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(outbox.PendingCount, Is.LessThanOrEqualTo(2)); release.TrySetResult(true); + await WaitUntilAsync(() => outbox.StateOf("move-1000") == + EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt); + Assert.That(outbox.Acknowledge("move-1000"), Is.True); await WaitUntilAsync(() => outbox.PendingCount == 0); lock (sent) { @@ -90,6 +100,57 @@ namespace GameCult.Eve.UnityScene.Tests } } + [Test] + public async Task CommandOutboxRetriesSameIdWithoutLettingPoisonCommandStarveLaterWork() + { + var attempts = new List(); + using var outbox = new EveUnityCultMeshCommandOutbox( + (request, _) => + { + lock (attempts) attempts.Add(request.CommandId); + if (request.CommandId == "poison") throw new IOException("route unavailable"); + return Task.CompletedTask; + }, + _ => null, + retryDelay: TimeSpan.FromMilliseconds(20), + maximumRetryDelay: TimeSpan.FromMilliseconds(40)); + + outbox.Enqueue(Request("poison", "dock")); + outbox.Enqueue(Request("healthy", "undock")); + + await WaitUntilAsync(() => outbox.StateOf("healthy") == + EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt); + Assert.That(outbox.Acknowledge("healthy"), Is.True); + lock (attempts) + { + Assert.That(attempts.IndexOf("healthy"), Is.GreaterThanOrEqualTo(0)); + Assert.That(attempts.FindAll(value => value == "poison"), Is.Not.Empty); + } + Assert.That(outbox.PendingCount, Is.EqualTo(1)); + } + + [Test] + public async Task CommandOutboxRetransmitsUnacknowledgedCommandWithOriginalId() + { + var attempts = 0; + using var outbox = new EveUnityCultMeshCommandOutbox( + (request, _) => + { + Assert.That(request.CommandId, Is.EqualTo("retry-me")); + Interlocked.Increment(ref attempts); + return Task.CompletedTask; + }, + _ => null, + retryDelay: TimeSpan.FromMilliseconds(20), + maximumRetryDelay: TimeSpan.FromMilliseconds(20)); + + outbox.Enqueue(Request("retry-me", "dock")); + await WaitUntilAsync(() => Volatile.Read(ref attempts) >= 2); + Assert.That(outbox.PendingCount, Is.EqualTo(1)); + Assert.That(outbox.Acknowledge("retry-me"), Is.True); + await WaitUntilAsync(() => outbox.PendingCount == 0); + } + [Test] public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute() { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 41e1b2e..8d7b92c 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.108", + "version": "0.3.109", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { diff --git a/scripts/run-package-tests.ps1 b/scripts/run-package-tests.ps1 index acd058d..6f4357a 100644 --- a/scripts/run-package-tests.ps1 +++ b/scripts/run-package-tests.ps1 @@ -3,6 +3,8 @@ param( [string] $CultLibRoot = "", [string] $EveRoot = "", [string] $EvePluginsRoot = "", + [string] $NuGetConfig = "", + [switch] $SkipCultLibBuild, [string] $ProjectRoot = "TestProject", [string] $OutputRoot = "artifacts\package-tests" ) @@ -33,8 +35,14 @@ foreach ($required in @( if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" } } -powershell -ExecutionPolicy Bypass -File $cultLibBuilder -if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" } +if (-not $SkipCultLibBuild) { + $cultLibBuildArguments = @("-ExecutionPolicy", "Bypass", "-File", $cultLibBuilder) + if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) { + $cultLibBuildArguments += @("-NuGetConfig", [IO.Path]::GetFullPath($NuGetConfig)) + } + & powershell @cultLibBuildArguments + if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" } +} $builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib" if (-not (Test-Path -LiteralPath $builtCultLibPackage)) { From 758f6860321edf22642e14c7820214e5abf6ce41 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 19:03:06 +0200 Subject: [PATCH 17/45] Recover Unity commands from durable provider receipts --- .../EveUnityCultMeshLiveProviderTransport.cs | 146 ++++++++++++++++-- 1 file changed, 133 insertions(+), 13 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 0ea02b6..30365c4 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,8 @@ namespace GameCult.Eve.UnityScene private readonly string _runtimeId; private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); + private readonly Dictionary> _receiptSubscriptions = + new Dictionary>(StringComparer.Ordinal); private readonly Dictionary _prefabs = new Dictionary(StringComparer.Ordinal); private readonly Dictionary _nativeAssets = new Dictionary(StringComparer.Ordinal); private readonly Dictionary> _nativeAssetMetadata = @@ -377,15 +379,24 @@ namespace GameCult.Eve.UnityScene private Task SendCommandAsync(EveSurfaceCommandRequest request, CancellationToken cancellationToken) { + return SendCommandWithReceiptLeaseAsync(request, cancellationToken); + } + + private async Task SendCommandWithReceiptLeaseAsync( + EveSurfaceCommandRequest request, + CancellationToken cancellationToken) + { + await EnsureReceiptSubscriptionAsync(request.CommandId, cancellationToken).ConfigureAwait(false); var interaction = RequireWorldInteraction(); var recordKey = ChildRecordKey(interaction.CommandRecordRef, request.CommandId); - return _meshClient!.SubmitDocumentAsync( - _target, - recordKey, - request, - _runtimeId, - "eve-unity", - cancellationToken); + await _meshClient!.SubmitDocumentAsync( + _target, + recordKey, + request, + _runtimeId, + "eve-unity", + cancellationToken) + .ConfigureAwait(false); } private string? ContinuousCommandKey(EveSurfaceCommandRequest request) @@ -399,7 +410,14 @@ namespace GameCult.Eve.UnityScene private void ForgetPendingCommand(string commandId) { - lock (_pendingCommandIds) _pendingCommandIds.Remove(commandId); + Task? subscription = null; + lock (_pendingCommandIds) + { + _pendingCommandIds.Remove(commandId); + if (_receiptSubscriptions.TryGetValue(commandId, out subscription)) + _receiptSubscriptions.Remove(commandId); + } + if (subscription != null) DisposeReceiptSubscription(subscription); } public void Dispose() @@ -442,6 +460,9 @@ namespace GameCult.Eve.UnityScene { _pendingCommandIds.Clear(); _publishedReceiptIds.Clear(); + foreach (var subscription in _receiptSubscriptions.Values) + DisposeReceiptSubscription(subscription); + _receiptSubscriptions.Clear(); } _lifetime.Dispose(); } @@ -1040,10 +1061,91 @@ namespace GameCult.Eve.UnityScene cancellationToken, publishInitial: false); } - await LeaseCollectionAsync( - receipt => _liveDocuments.Enqueue(receipt), - cancellationToken, - includeInitialSnapshot: false); + var interaction = RequireWorldInteraction(); + if (!string.Equals(interaction.ReceiptSchema, EveCommandReceiptDocument.SchemaId, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"The provider advertises receipt schema '{interaction.ReceiptSchema}', expected '{EveCommandReceiptDocument.SchemaId}'."); + if (string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef)) + throw new InvalidOperationException("The provider advertisement does not publish a receipt record reference."); + } + + private Task EnsureReceiptSubscriptionAsync( + string commandId, + CancellationToken cancellationToken) + { + Task subscription; + lock (_pendingCommandIds) + { + if (_receiptSubscriptions.TryGetValue(commandId, out subscription!)) return subscription; + subscription = OpenReceiptSubscriptionAsync(commandId, cancellationToken); + _receiptSubscriptions.Add(commandId, subscription); + } + return RemoveFailedReceiptSubscriptionAsync(commandId, subscription); + } + + private async Task RemoveFailedReceiptSubscriptionAsync( + string commandId, + Task subscription) + { + try { return await subscription.ConfigureAwait(false); } + catch + { + lock (_pendingCommandIds) + { + if (_receiptSubscriptions.TryGetValue(commandId, out var current) && + ReferenceEquals(current, subscription)) + _receiptSubscriptions.Remove(commandId); + } + throw; + } + } + + private async Task OpenReceiptSubscriptionAsync( + string commandId, + CancellationToken cancellationToken) + { + var recordKey = ChildRecordKey(RequireWorldInteraction().ReceiptRecordRef, commandId); + var lease = await _meshClient!.LeaseDocumentAsync( + _target, + recordKey, + cancellationToken) + .ConfigureAwait(false); + IDisposable? watch = null; + try + { + watch = lease.Handle.Watch(receipt => QueueReceipt(commandId, receipt)); + try { QueueReceipt(commandId, await lease.Handle.LatestAsync().ConfigureAwait(false)); } + catch (KeyNotFoundException) { } + return new ReceiptSubscription(lease, watch); + } + catch + { + watch?.Dispose(); + lease.Dispose(); + throw; + } + } + + private void QueueReceipt(string expectedCommandId, EveCommandReceiptDocument receipt) + { + if (receipt == null || + !string.Equals(receipt.CommandId, expectedCommandId, StringComparison.Ordinal) || + !string.Equals(receipt.ProviderId, _providerId, StringComparison.Ordinal) || + !string.Equals(receipt.SurfaceId, _surfaceId, StringComparison.Ordinal)) + return; + _liveDocuments.Enqueue(receipt); + } + + private static void DisposeReceiptSubscription(Task subscription) + { + if (subscription.Status == TaskStatus.RanToCompletion) + subscription.Result.Dispose(); + else + _ = subscription.ContinueWith( + completed => { if (completed.Status == TaskStatus.RanToCompletion) completed.Result.Dispose(); }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); } private void DisposeLiveDocuments() @@ -1470,9 +1572,9 @@ namespace GameCult.Eve.UnityScene { if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) return; - _pendingCommandIds.Remove(receipt.CommandId); } _commandOutbox?.Acknowledge(receipt.CommandId); + ForgetPendingCommand(receipt.CommandId); CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, @@ -1496,6 +1598,24 @@ namespace GameCult.Eve.UnityScene receipt.Navigation.RendezvousEndpoints))); } + private sealed class ReceiptSubscription : IDisposable + { + private IDisposable? _lease; + private IDisposable? _watch; + + public ReceiptSubscription(IDisposable lease, IDisposable watch) + { + _lease = lease; + _watch = watch; + } + + public void Dispose() + { + Interlocked.Exchange(ref _watch, null)?.Dispose(); + Interlocked.Exchange(ref _lease, null)?.Dispose(); + } + } + private EveWorldInteractionAdvertisement RequireWorldInteraction() { return _advertisedSurface?.WorldInteraction From ae48ea031dd21fb4e01f45a05c575ad8b23c693b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 19:41:25 +0200 Subject: [PATCH 18/45] Thread CultMesh trust through Unity provider discovery --- .../EveUnityCultMeshLiveProviderTransport.cs | 11 +++++++---- .../EveUnityCultMeshPlayableWorldProvider.cs | 12 +++++++++--- .../CultMesh/EveUnityCultMeshProviderDiscovery.cs | 11 ++++++++++- packages/org.gamecult.eve.unity-scene/package.json | 6 +++--- 4 files changed, 29 insertions(+), 11 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 30365c4..a5d054a 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -30,8 +30,8 @@ namespace GameCult.Eve.UnityScene private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); - private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); private readonly Dictionary> _receiptSubscriptions = new Dictionary>(StringComparer.Ordinal); private readonly Dictionary _prefabs = new Dictionary(StringComparer.Ordinal); @@ -95,7 +95,8 @@ namespace GameCult.Eve.UnityScene string providerId, string surfaceId, string runtimeId = "eve-unity", - CultMeshBodyPublicationResolver? bodyResolver = null) + CultMeshBodyPublicationResolver? bodyResolver = null, + CultMeshAuthorityTrustPolicy? authorityTrust = null) { _cachePath = string.IsNullOrWhiteSpace(cachePath) ? throw new ArgumentException("Cache path must be non-empty.", nameof(cachePath)) @@ -112,6 +113,8 @@ namespace GameCult.Eve.UnityScene : surfaceId.Trim(); _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); _bodyResolver = bodyResolver; + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); CurrentInputCapability = new EveInputCapabilityDocument(); @@ -459,7 +462,6 @@ namespace GameCult.Eve.UnityScene lock (_pendingCommandIds) { _pendingCommandIds.Clear(); - _publishedReceiptIds.Clear(); foreach (var subscription in _receiptSubscriptions.Values) DisposeReceiptSubscription(subscription); _receiptSubscriptions.Clear(); @@ -511,6 +513,7 @@ namespace GameCult.Eve.UnityScene _meshClient = new CultMeshClient(new CultMeshClientOptions { RendezvousEndpoints = new[] { _rendezvousEndpoint }, + Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust }, RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] { new CultMeshNativeQuicRealtimeTransportConnector() @@ -1570,7 +1573,7 @@ namespace GameCult.Eve.UnityScene { lock (_pendingCommandIds) { - if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) + if (!_pendingCommandIds.Remove(receipt.CommandId)) return; } _commandOutbox?.Acknowledge(receipt.CommandId); 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 6f049bf..651a7ca 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -40,6 +40,8 @@ namespace GameCult.Eve.UnityScene [FormerlySerializedAs("replicaPath")] [SerializeField] private string cacheDirectory = ""; [SerializeField] private string runtimeId = "eve-unity"; + private CultMeshAuthorityTrustPolicy _authorityTrust = new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; @@ -87,7 +89,8 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string verseId = "", string requiredSurfaceKind = "interactive-world", - string clientRuntimeId = "eve-unity") + string clientRuntimeId = "eve-unity", + CultMeshAuthorityTrustPolicy? authorityTrust = null) { if (_bridge != null || _preparation != null) throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); @@ -99,6 +102,8 @@ namespace GameCult.Eve.UnityScene verseFilter = verseId ?? ""; surfaceKind = string.IsNullOrWhiteSpace(requiredSurfaceKind) ? "interactive-world" : requiredSurfaceKind; runtimeId = string.IsNullOrWhiteSpace(clientRuntimeId) ? "eve-unity" : clientRuntimeId; + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); } public void Connect() @@ -245,7 +250,7 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync( + Selection = await new EveUnityCultMeshProviderDiscovery(_authorityTrust).DiscoverAsync( rendezvousEndpoint, providerFilter, surfaceFilter, @@ -264,7 +269,8 @@ namespace GameCult.Eve.UnityScene Selection.AuthorityRuntimeId, Selection.ProviderId, Selection.SurfaceId, - runtimeId); + runtimeId, + authorityTrust: _authorityTrust); try { await transport.PrepareAsync(cancellationToken); 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 6628f28..7a5bdfc 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -39,6 +39,14 @@ namespace GameCult.Eve.UnityScene public sealed class EveUnityCultMeshProviderDiscovery { + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; + + public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null) + { + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + } + public async Task DiscoverAsync( string rendezvousEndpoint, string providerId = "", @@ -85,7 +93,8 @@ namespace GameCult.Eve.UnityScene var observed = new List(); using var mesh = new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = new[] { rendezvousEndpoint } + RendezvousEndpoints = new[] { rendezvousEndpoint }, + Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust } }); foreach (var candidate in candidates) { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 8d7b92c..4231a43 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,13 +1,13 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.109", + "version": "0.3.110", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.47", + "org.gamecult.cultlib": "1.0.48", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.2", + "org.gamecult.eve.surface": "0.3.3", "org.gamecult.eve.unity-uitoolkit": "0.1.4", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", From e8e116ecc48d61a3d78642eab77f5a31d20e9ae3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 20:14:02 +0200 Subject: [PATCH 19/45] Carry authenticated Verse transport through Unity --- .../CultMesh/EveUnityCultMeshConnectivity.cs | 49 +++++++++++++++++++ .../EveUnityCultMeshConnectivity.cs.meta | 2 + .../EveUnityCultMeshLiveProviderTransport.cs | 3 ++ .../EveUnityCultMeshPlayableWorldProvider.cs | 9 +++- .../EveUnityCultMeshProviderDiscovery.cs | 7 ++- .../org.gamecult.eve.unity-scene/package.json | 6 +-- .../package.json | 4 +- 7 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs new file mode 100644 index 0000000..5e163c8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs @@ -0,0 +1,49 @@ +using System; +using GameCult.Mesh; +using GameCult.Networking; +using GameCult.Networking.WebSockets; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + /// + /// Owns the transport set used by EveUnity for both rendezvous discovery and + /// the selected provider. Trust remains caller-owned; this class only makes + /// every supported secure plane reachable through the same configuration. + /// + internal static class EveUnityCultMeshConnectivity + { + public static CultMeshVerseDiscoveryClientOptions Discovery() => new() + { + CreateClientForEndpoint = CreateSchemaClient + }; + + public static ICultMeshTransportConnector[] SchemaConnectors() => new ICultMeshTransportConnector[] + { + new CultMeshTcpSchemaTransportConnector(), + new CultMeshUriSchemaTransportConnector( + "cultnet-websocket", + new[] { "ws", "wss" }, + _ => new CultNetWebSocketSchemaClient()) + }; + + public static ICultMeshContentTransportConnector[] ContentConnectors() => + new ICultMeshContentTransportConnector[] + { + new CultMeshHttpsContentTransportConnector(), + new CultMeshTcpContentTransportConnector() + }; + + private static ICultNetSchemaClient CreateSchemaClient(string endpoint) + { + if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && + (string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase))) + { + return new CultNetWebSocketSchemaClient(); + } + return CultNetSchemaClients.CreateForEndpoint(endpoint); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta new file mode 100644 index 0000000..a701492 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f7cb06716fd4f319ade147a69acb74a 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 a5d054a..ae746ec 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -513,7 +513,10 @@ namespace GameCult.Eve.UnityScene _meshClient = new CultMeshClient(new CultMeshClientOptions { RendezvousEndpoints = new[] { _rendezvousEndpoint }, + Discovery = EveUnityCultMeshConnectivity.Discovery(), Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors(), RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] { new CultMeshNativeQuicRealtimeTransportConnector() 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 651a7ca..056d652 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -42,6 +42,8 @@ namespace GameCult.Eve.UnityScene [SerializeField] private string runtimeId = "eve-unity"; private CultMeshAuthorityTrustPolicy _authorityTrust = new CultMeshAuthorityTrustPolicy( CultMeshAuthorityTrustMode.AuthenticatedRemote); + private CultMeshAuthorityTrustPolicy _navigationAuthorityTrust = new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; @@ -90,7 +92,8 @@ namespace GameCult.Eve.UnityScene string verseId = "", string requiredSurfaceKind = "interactive-world", string clientRuntimeId = "eve-unity", - CultMeshAuthorityTrustPolicy? authorityTrust = null) + CultMeshAuthorityTrustPolicy? authorityTrust = null, + CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null) { if (_bridge != null || _preparation != null) throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); @@ -104,6 +107,7 @@ namespace GameCult.Eve.UnityScene runtimeId = string.IsNullOrWhiteSpace(clientRuntimeId) ? "eve-unity" : clientRuntimeId; _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( CultMeshAuthorityTrustMode.AuthenticatedRemote); + _navigationAuthorityTrust = navigationAuthorityTrust ?? _authorityTrust; } public void Connect() @@ -141,7 +145,10 @@ namespace GameCult.Eve.UnityScene var navigationEndpoint = target.RendezvousEndpoints .FirstOrDefault(endpoint => !string.IsNullOrWhiteSpace(endpoint)); if (!string.IsNullOrWhiteSpace(navigationEndpoint)) + { rendezvousEndpoint = navigationEndpoint; + _authorityTrust = _navigationAuthorityTrust; + } verseFilter = target.VerseId; providerFilter = target.ProviderId; surfaceFilter = target.SurfaceId; 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 7a5bdfc..fd43418 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -61,7 +61,7 @@ namespace GameCult.Eve.UnityScene CultMeshVerseCatalogResponseMessage response; try { - response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync( + response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync( rendezvousEndpoint, new CultMeshVerseCatalogRequestMessage { @@ -94,7 +94,10 @@ namespace GameCult.Eve.UnityScene using var mesh = new CultMeshClient(new CultMeshClientOptions { RendezvousEndpoints = new[] { rendezvousEndpoint }, - Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust } + Discovery = EveUnityCultMeshConnectivity.Discovery(), + Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors() }); foreach (var candidate in candidates) { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 4231a43..d59d7bf 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,14 +1,14 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.110", + "version": "0.3.111", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.48", + "org.gamecult.cultlib": "1.0.49", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.3", - "org.gamecult.eve.unity-uitoolkit": "0.1.4", + "org.gamecult.eve.unity-uitoolkit": "0.1.5", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" diff --git a/packages/org.gamecult.eve.unity-uitoolkit/package.json b/packages/org.gamecult.eve.unity-uitoolkit/package.json index f4f63dc..9c59432 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/package.json +++ b/packages/org.gamecult.eve.unity-uitoolkit/package.json @@ -1,11 +1,11 @@ { "name": "org.gamecult.eve.unity-uitoolkit", "displayName": "Eve UI Toolkit Lowering", - "version": "0.1.4", + "version": "0.1.5", "unity": "2021.3", "description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.", "dependencies": { - "org.gamecult.eve.surface": "0.3.2" + "org.gamecult.eve.surface": "0.3.3" }, "keywords": [ "Eve", From de91053dd4b37b9f0cce1493db4e3e56ef869856 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 20:54:14 +0200 Subject: [PATCH 20/45] Make Eve navigation and Hangar lowering transactional --- .../EveUnityCultMeshPlayableWorldProvider.cs | 147 +++++++++++++----- .../EveUnityPlayableWorldClientBootstrap.cs | 17 ++ .../org.gamecult.eve.unity-scene/package.json | 4 +- .../Runtime/EveUiToolkitSurfaceLowerer.cs | 35 ++++- .../Editor/EveUiToolkitSurfaceLowererTests.cs | 63 ++++++++ .../package.json | 2 +- 6 files changed, 224 insertions(+), 44 deletions(-) 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 056d652..dea2a47 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -141,19 +141,36 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(target.SurfaceId)) throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target)); - ReleaseTransport(); - var navigationEndpoint = target.RendezvousEndpoints - .FirstOrDefault(endpoint => !string.IsNullOrWhiteSpace(endpoint)); - if (!string.IsNullOrWhiteSpace(navigationEndpoint)) - { - rendezvousEndpoint = navigationEndpoint; - _authorityTrust = _navigationAuthorityTrust; - } + var endpoints = (target.RendezvousEndpoints ?? Array.Empty()) + .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint)) + .Select(endpoint => endpoint.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (endpoints.Length == 0 && !string.IsNullOrWhiteSpace(rendezvousEndpoint)) + endpoints = new[] { rendezvousEndpoint }; + if (endpoints.Length == 0) + throw new InvalidOperationException("Eve provider navigation requires at least one Odin rendezvous endpoint."); + + var requiredSurfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) + ? "interactive-world" + : target.SurfaceKind; + var prepared = await PrepareCandidateAsync( + endpoints, + target.ProviderId, + target.SurfaceId, + requiredSurfaceKind, + target.VerseId, + _navigationAuthorityTrust, + CancellationToken.None).ConfigureAwait(false); + + AdoptPrepared(prepared); + rendezvousEndpoint = prepared.Selection.RendezvousEndpoint; verseFilter = target.VerseId; providerFilter = target.ProviderId; surfaceFilter = target.SurfaceId; - surfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) ? "interactive-world" : target.SurfaceKind; - await PrepareAsync(); + surfaceKind = requiredSurfaceKind; + _authorityTrust = _navigationAuthorityTrust; + _preparation = Task.CompletedTask; } public void Refresh() @@ -257,44 +274,97 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - Selection = await new EveUnityCultMeshProviderDiscovery(_authorityTrust).DiscoverAsync( - rendezvousEndpoint, + var prepared = await PrepareCandidateAsync( + new[] { rendezvousEndpoint }, providerFilter, surfaceFilter, surfaceKind, verseFilter, - cancellationToken); - TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); - elapsed.Restart(); - var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) - ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") - : cacheDirectory; - var transport = new EveUnityCultMeshLiveProviderTransport( - resolvedCachePath, - Selection.RendezvousEndpoint, - Selection.VerseId, - Selection.AuthorityRuntimeId, - Selection.ProviderId, - Selection.SurfaceId, - runtimeId, - authorityTrust: _authorityTrust); - try + _authorityTrust, + cancellationToken).ConfigureAwait(false); + TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); + AdoptPrepared(prepared); + } + + private async Task PrepareCandidateAsync( + IReadOnlyList endpoints, + string requiredProviderId, + string requiredSurfaceId, + string requiredSurfaceKind, + string requiredVerseId, + CultMeshAuthorityTrustPolicy trust, + CancellationToken cancellationToken) + { + var failures = new List(); + foreach (var endpoint in endpoints) { - await transport.PrepareAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + try + { + var selection = await new EveUnityCultMeshProviderDiscovery(trust).DiscoverAsync( + endpoint, + requiredProviderId, + requiredSurfaceId, + requiredSurfaceKind, + requiredVerseId, + cancellationToken).ConfigureAwait(false); + var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) + ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") + : cacheDirectory; + var transport = new EveUnityCultMeshLiveProviderTransport( + resolvedCachePath, + selection.RendezvousEndpoint, + selection.VerseId, + selection.AuthorityRuntimeId, + selection.ProviderId, + selection.SurfaceId, + runtimeId, + authorityTrust: trust); + try + { + await transport.PrepareAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new PreparedProvider(selection, transport, new EveUnitySceneLiveProviderBridge(transport)); + } + catch + { + transport.Dispose(); + throw; + } + } + catch (Exception error) when (error is not OperationCanceledException) + { + failures.Add($"{endpoint}: {error.Message}"); + } } - catch + + throw new InvalidOperationException( + $"No configured Odin endpoint prepared Verse '{requiredVerseId}' surface '{requiredSurfaceId}'. " + + string.Join(" | ", failures)); + } + + private void AdoptPrepared(PreparedProvider prepared) + { + var oldBridge = _bridge; + var oldTransport = _transport; + if (oldBridge != null) { - transport.Dispose(); - throw; + oldBridge.DocumentAvailable -= ForwardDocument; + oldBridge.ReceiptAvailable -= ForwardReceipt; } - cancellationToken.ThrowIfCancellationRequested(); - _transport = transport; + + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; _transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); _transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields); - _bridge = new EveUnitySceneLiveProviderBridge(_transport); _bridge.DocumentAvailable += ForwardDocument; _bridge.ReceiptAvailable += ForwardReceipt; - TraceStartup($"transport-construction {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); + + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + oldBridge?.Dispose(); + oldTransport?.Dispose(); } private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) => @@ -327,5 +397,10 @@ namespace GameCult.Eve.UnityScene public EveEntitySoaViewDocument Document { get; } public ICultMeshBodyReadLease Lease { get; } } + + private sealed record PreparedProvider( + EveUnityCultMeshProviderSelection Selection, + EveUnityCultMeshLiveProviderTransport Transport, + EveUnitySceneLiveProviderBridge Bridge); } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index ffe1791..cb8a372 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -30,6 +30,10 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; } + public EveUnitySceneNavigationFailure? LastNavigationFailure { get; private set; } + + public event Action? NavigationFailed; + private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource; private bool _navigationInProgress; @@ -82,8 +86,16 @@ namespace GameCult.Eve.UnityScene try { navigation.GetAwaiter().GetResult(); + LastNavigationFailure = null; Mount(); } + catch (Exception error) + { + var failure = new EveUnitySceneNavigationFailure(target, error.Message, DateTimeOffset.UtcNow); + LastNavigationFailure = failure; + NavigationFailed?.Invoke(failure); + Debug.LogError($"Eve provider navigation failed; the current surface remains mounted. {error}"); + } finally { _navigationInProgress = false; @@ -243,4 +255,9 @@ namespace GameCult.Eve.UnityScene return null; } } + + public sealed record EveUnitySceneNavigationFailure( + EveUnitySceneNavigationTarget Target, + string Diagnostic, + DateTimeOffset FailedAtUtc); } diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index d59d7bf..3bfdf79 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,14 +1,14 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.111", + "version": "0.3.112", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { "org.gamecult.cultlib": "1.0.49", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.3", - "org.gamecult.eve.unity-uitoolkit": "0.1.5", + "org.gamecult.eve.unity-uitoolkit": "0.1.6", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index 8b82b3d..2506d96 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -46,6 +46,7 @@ namespace GameCult.Eve.UnityUIToolkit element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}"); element.userData = component; ApplyGeneratedLayout(element, component); + element.SetEnabled(IsEnabled(component)); if (IsHidden(component) || IsExternalProjectionRoot(component.Kind)) return element; @@ -131,6 +132,8 @@ namespace GameCult.Eve.UnityUIToolkit element.style.flexWrap = Wrap.Wrap; return element; } + case "scroll": + return new ScrollView(ScrollViewMode.Vertical); case "pane": case "modal": case "card": @@ -538,18 +541,26 @@ namespace GameCult.Eve.UnityUIToolkit }) .Where(option => !string.IsNullOrWhiteSpace(option.Value)) .ToArray(); - var labels = options.Select(option => option.Label).ToList(); + var duplicateLabels = options + .GroupBy(option => option.Label, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + var labels = options + .Select(option => duplicateLabels.Contains(option.Label) + ? $"{option.Label} [{option.Value}]" + : option.Label) + .ToList(); var selectedValue = component.GetProp("value"); var selectedIndex = Array.FindIndex(options, option => string.Equals(option.Value, selectedValue, StringComparison.Ordinal)); var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex)); - field.SetEnabled(!ParseBool(component.GetProp("disabled"))); field.RegisterValueChangedCallback(change => { - var option = options.FirstOrDefault(candidate => - string.Equals(candidate.Label, change.newValue, StringComparison.Ordinal)); - if (option == null) + var index = field.index; + if (index < 0 || index >= options.Length) return; + var option = options[index]; var payload = new Dictionary(component.Props, StringComparer.Ordinal) { ["value"] = option.Value @@ -659,6 +670,12 @@ namespace GameCult.Eve.UnityUIToolkit element.style.alignItems = align; if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify)) element.style.justifyContent = justify; + if (TryGet(layout, "flexGrow", out var flexGrow)) + element.style.flexGrow = ParseFloat(flexGrow); + if (TryGet(layout, "flexShrink", out var flexShrink)) + element.style.flexShrink = ParseFloat(flexShrink); + if (TryGet(layout, "flexBasis", out var flexBasis)) + element.style.flexBasis = ParseLength(flexBasis); if (TryGet(layout, "width", out var width)) element.style.width = ParseLength(width); if (TryGet(layout, "minWidth", out var minWidth)) @@ -735,6 +752,14 @@ namespace GameCult.Eve.UnityUIToolkit return false; } + private static bool IsEnabled(EveSurfaceComponent component) + { + if (ParseBool(component.GetProp("disabled"))) + return false; + var enabled = component.GetProp("enabled"); + return string.IsNullOrWhiteSpace(enabled) || ParseBool(enabled); + } + private static StyleLength ParseLength(string value) { value = FirstToken(value); diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs index 85d664b..6eba639 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -52,6 +52,69 @@ namespace GameCult.Eve.UnityUIToolkit.Tests } } + [Test] + public void SelectKeepsStableValuesWhenVerseLabelsCollide() + { + EveSurfaceCommandRequest? emitted = null; + var select = Component( + "verse", + "control.select", + new Dictionary + { + ["label"] = "VERSE", + ["value"] = "verse:a", + ["command"] = "eve.client.verse.select" + }, + new[] + { + Component("a", "control.option", new Dictionary { ["label"] = "Aetheria", ["value"] = "verse:a" }), + Component("b", "control.option", new Dictionary { ["label"] = "Aetheria", ["value"] = "verse:b" }) + }); + var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request); + var field = root.Q(); + var window = EditorWindow.CreateInstance(); + + try + { + window.rootVisualElement.Add(root); + window.Show(); + Assert.That(field.choices, Is.EqualTo(new[] { "Aetheria [verse:a]", "Aetheria [verse:b]" })); + field.index = 1; + Assert.That(emitted, Is.Not.Null); + Assert.That(emitted!.PayloadFields["value"], Is.EqualTo("verse:b")); + } + finally + { + window.Close(); + } + } + + [Test] + public void SemanticEnabledStateAndMinMaxFlexLayoutAreLowered() + { + var button = new EveSurfaceComponent( + "launch", + "control.button", + new Dictionary { ["label"] = "LAUNCH", ["disabled"] = "true" }, + Array.Empty(), + Array.Empty(), + Array.Empty(), + new Dictionary + { + ["minWidth"] = "280", + ["maxWidth"] = "360", + ["flexGrow"] = "1" + }); + + var root = new EveUiToolkitSurfaceLowerer().Lower(Document(button)); + var lowered = root.Q