From 3af959bf4f90ddfacb94c5846243697110690089 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 05:26:37 +0200 Subject: [PATCH 01/37] 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 02/37] 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 03/37] 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 04/37] 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 05/37] 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 06/37] 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 07/37] 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 08/37] 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 09/37] 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 10/37] 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 11/37] 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 12/37] 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