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/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/ReleaseConsumerProject/Packages/manifest.json b/ReleaseConsumerProject/Packages/manifest.json index 07406db..b82d19d 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#334e60f1928b4212a29dd8b0d19b2c099fe6365e", "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#96839ad34c8d464ef622d8bbdd5d277e1ca9d825", + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9", + "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "testables": [ "org.gamecult.eve.unity-scene", diff --git a/ReleaseConsumerProject/Packages/packages-lock.json b/ReleaseConsumerProject/Packages/packages-lock.json index 4510f6a..8ad82a2 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#334e60f1928b4212a29dd8b0d19b2c099fe6365e", "depth": 0, "source": "git", "dependencies": {}, - "hash": "03840d0430bc727f1322861a1ab46b396eaca860" + "hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e" }, "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#96839ad34c8d464ef622d8bbdd5d277e1ca9d825", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.45" + "org.gamecult.cultlib": "1.0.56" }, - "hash": "fe0dddf28267decbb416325a3d0b2c62432825c2" + "hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825" }, "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#d1663b36d12b60ff5c7249135eff48edf6227ca9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.45", + "org.gamecult.cultlib": "1.0.56", "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.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" }, - "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" + "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "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#d1663b36d12b60ff5c7249135eff48edf6227ca9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.eve.surface": "0.3.0" + "org.gamecult.eve.surface": "0.3.6" }, - "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" + "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "com.unity.modules.assetbundle": { "version": "1.0.0", 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/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index bbda013..d631673 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -137,7 +137,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.45" + "org.gamecult.cultlib": "1.0.56" } }, "org.gamecult.eve.unity-scene": { @@ -145,10 +145,10 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.45", + "org.gamecult.cultlib": "1.0.56", "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.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", "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.6" } }, "com.unity.modules.assetbundle": { diff --git a/docs/aetheria-released-package-witness.md b/docs/aetheria-released-package-witness.md index b38f763..6446cec 100644 --- a/docs/aetheria-released-package-witness.md +++ b/docs/aetheria-released-package-witness.md @@ -4,26 +4,26 @@ The generic `ReleaseConsumerProject` is pinned to released Git packages: -- `org.gamecult.eve.unity-scene` `0.3.100`, commit - `3632bfb3354065b78a90b921d7025c4c3fa92be4`, and - `org.gamecult.eve.surface` `0.2.4`, commit - `e08fa08335f99e9edddeb706912eecfad07cb281`; +- `org.gamecult.eve.unity-scene` `0.3.134`, commit + `d1663b36d12b60ff5c7249135eff48edf6227ca9`, and + `org.gamecult.eve.surface` `0.3.6`, commit + `96839ad34c8d464ef622d8bbdd5d277e1ca9d825`; - `org.gamecult.eve.plugin-fields` `0.2.3`, commit `c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`; -- `org.gamecult.eve.unity-uitoolkit` `0.1.1`, commit - `4d0cbe0185bdc4fc65eb63503a7c5cb578539669`; -- `org.gamecult.cultlib` `1.0.43`, commit - `f67f5122ed1bd11da016e7b820ed60145ccd0299`. +- `org.gamecult.eve.unity-uitoolkit` `0.1.9`, commit + `d1663b36d12b60ff5c7249135eff48edf6227ca9`; +- `org.gamecult.cultlib` `1.0.56`, commit + `334e60f1928b4212a29dd8b0d19b2c099fe6365e`. The hand-run integration gates are the cold lowering proof and the warm full-session proof: ```powershell pwsh -File .\scripts\run-aetheria-daemon-world-witness.ps1 ` - -CultLibRoot E:\Projects\CultLib-codex-cultmesh-reliability ` + -CultLibRoot E:\Projects\CultLib ` -CacheState cold ` - -AssetCacheDirectory artifacts\cultlib-1043-scene-03100-cold-cache ` - -OutputDirectory artifacts\cultlib-1043-scene-03100-cold ` + -AssetCacheDirectory artifacts\admitted-release-cold-cache ` + -OutputDirectory artifacts\admitted-release-cold ` -SkipAssetBundleBuild ``` @@ -31,9 +31,13 @@ Both connect directly to the Aetheria daemon. Control, subscriptions, commands, and receipts use `cultnet+tcp`; provider files use `cultmesh-content+tcp`; same-machine entity state uses a mapped SoA body; and the released native QUIC client receives the advertised latest-only realtime body. No Odin hop or -Aetheria-specific gameplay code participates in the Unity runtime. +Aetheria-specific gameplay code participates in the Unity runtime. This +admission pass proves the package graph with the isolated EditMode consumer; it +does not claim a new rendered end-to-end witness. -The current cold proof is +## Historical witness ledger + +The retained cold proof is `artifacts/cultlib-1043-scene-03100-cold5/runtime-witness.cold.json`. It began with no bodies or partials and passed in 57.8 seconds end to end (14.9 seconds inside PlayMode). The client materialized nine requested bundles totaling @@ -45,7 +49,7 @@ unloaded. This cold profile owns content transfer, world lowering, fog and Stardust execution, and pilot/map camera isolation. It deliberately issues no gameplay command into the paused Terminus fixture. -The current warm full-session proof is +The retained warm full-session proof is `artifacts/cultlib-1043-scene-03100-warm/runtime-witness.warm.json`. It passed in 70.7 seconds end to end (27.9 seconds inside PlayMode). The generic client docks, buys and sells one typed item, resumes and undocks, moves, targets, aims, holds @@ -63,8 +67,6 @@ visual-lever and shader-calibration issues, not concealed transport failures. The map capture is clean and contains only map-channel glyphs on its dark map background. -## Historical witness ledger - An earlier released-package cold world witness passed with: - `org.gamecult.eve.unity-scene` `0.3.63`, commit diff --git a/docs/authority-map.md b/docs/authority-map.md index 4b25203..3be5f6c 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -76,6 +76,13 @@ 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; +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 @@ -98,8 +105,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..52d0a60 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -16,20 +16,47 @@ 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 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 +while a provider is slow or temporarily unreachable. + +An Eve world may qualify its asset catalog with a different provider, Verse, +authority runtime, and Odin rendezvous set than the surface provider. The +transport resolves that exact asset session and commits assets as one generation: +catalog, verified bundles, leases, lookups, render policy, and surface lowering +are prepared off to the side, then swapped together. A failed or superseded +candidate is disposed without unloading or rewriting the mounted generation. +The mounted provider's trust policy authenticates only that provider. A +provider-qualified asset source in another Verse/runtime uses the configured +cross-target Odin trust for its catalog, subscription, manifests, content, and +bundles; local-development trust does not cross that boundary. +The candidate opens its catalog lease before preload, observes updates in a +candidate-owned buffer, and verifies the leased version again before commit. +An update that lands during preload therefore rebuilds the candidate instead +of disappearing between a one-shot read and a later subscription. + +Navigation keeps the outgoing presentation quiesced until the candidate route +is ready, then commits the provider route before activating the candidate. +Presentation connections are provider-owned leases: disposing the outgoing +host releases only its lease and cannot disconnect the newly committed Verse. The package never imports provider product assemblies. @@ -149,3 +176,13 @@ authority. Native volume programs may advertise a `cameraToWorld` matrix port when their procedural passes require an explicit camera transform instead of relying on render-pipeline globals. + +Command finality is presentation-causal. When a terminal receipt names a +`sourceVersion` newer than the mounted surface, EveUnity keeps the command and +its exact receipt lease alive, makes the obsolete surface read-only, and emits +the terminal receipt only after that surface generation and its qualified asset +catalog have committed together. Asset preparation failure therefore cannot +leave an accepted Verse switch paired with actionable controls from the prior +Verse. Embedded surfaces may recompose the visible tree, but their versions are +derived rendering inputs and cannot satisfy or clear the advertised base +surface's finality barrier. 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..f8c89fa --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs @@ -0,0 +1,296 @@ +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 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 List _items = new List(); + private readonly Dictionary _byCommandId = + new Dictionary(StringComparer.Ordinal); + 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 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 long _sequence; + + public EveUnityCultMeshCommandOutbox( + Func send, + Func continuousKey, + Action? discard = null, + int commandCapacity = 256, + 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)); + _discard = discard ?? (_ => { }); + if (commandCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(commandCapacity)); + 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 _items.Count; } + } + + internal DeliveryState? StateOf(string commandId) + { + lock (_gate) + return _byCommandId.TryGetValue(commandId, out var item) ? item.State : null; + } + + 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; + 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); + key = string.IsNullOrWhiteSpace(key) ? null : key; + if (key == null) + { + if (_items.Count(item => item.ContinuousKey == null) >= _commandCapacity) + throw new InvalidOperationException( + $"The EveUnity command outbox reached its {_commandCapacity}-command bound."); + } + else if (_continuous.TryGetValue(key, out var previous)) + { + RemoveLocked(previous); + discarded = previous.Request.CommandId; + } + else if (_continuous.Count >= _continuousCapacity) + { + 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 (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() + { + OutboxItem[] discarded; + lock (_gate) + { + if (_disposed) return; + _disposed = true; + discarded = _items.ToArray(); + _items.Clear(); + _byCommandId.Clear(); + _continuous.Clear(); + } + foreach (var item in discarded) _discard(item.Request.CommandId); + _lifetime.Cancel(); + _changed.Release(); + } + + private async Task DeliverAsync() + { + var cancellationToken = _lifetime.Token; + try + { + while (true) + { + var selection = TakeNext(); + if (selection.Item == null) + { + await WaitForChangeOrDueAsync(selection.Delay, cancellationToken).ConfigureAwait(false); + continue; + } + await DeliverOnceAsync(selection.Item, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + 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) + { + var now = DateTimeOffset.UtcNow; + var item = _items + .Where(candidate => candidate.State != DeliveryState.Sending && candidate.NextAttemptAt <= now) + .OrderBy(candidate => candidate.Sequence) + .FirstOrDefault(); + if (item != null) + { + item.State = DeliveryState.Sending; + return new Selection(item, null); + } + 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 async Task WaitForChangeOrDueAsync(TimeSpan? delay, CancellationToken cancellationToken) + { + 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, 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/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/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 06a3175..5a403f4 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -24,29 +24,24 @@ namespace GameCult.Eve.UnityScene IEveUnityGameObjectAssetProvider, IDisposable { + private const int AssetPresentationAttemptLimit = 3; + 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; - private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); - private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); - private readonly Dictionary _prefabs = new Dictionary(StringComparer.Ordinal); - private readonly Dictionary _nativeAssets = new Dictionary(StringComparer.Ordinal); - private readonly Dictionary> _nativeAssetMetadata = - new Dictionary>(StringComparer.Ordinal); - private readonly List _assetBundles = new List(); - private readonly Dictionary _assetSelections = - new Dictionary(StringComparer.Ordinal); - private readonly Dictionary> _bundleSelections = - new Dictionary>(StringComparer.Ordinal); - private readonly Dictionary _bundleVariants = - new Dictionary(StringComparer.Ordinal); - private readonly HashSet _loadedBundleUris = new HashSet(StringComparer.Ordinal); - private readonly HashSet _loadingBundleUris = new HashSet(StringComparer.Ordinal); - private readonly List _assetBodyLeases = new List(); - private readonly Dictionary _renderChannelLayers = new Dictionary(StringComparer.Ordinal); + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; + private readonly CultMeshAuthorityTrustPolicy _crossTargetAuthorityTrust; + private readonly Dictionary _pendingCommands = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _receiptSubscriptions = + new Dictionary>(StringComparer.Ordinal); + private readonly object _presentationFinalityGate = new object(); + private readonly Dictionary _deferredTerminalReceipts = + new Dictionary(StringComparer.Ordinal); + private readonly HashSet _continuousCommandIds = new HashSet(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); private readonly object _realtimeFrameGate = new object(); private readonly Dictionary _latestRealtimeFrames = @@ -57,11 +52,12 @@ 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 readonly SemaphoreSlim _assetGenerationGate = new SemaphoreSlim(1, 1); private CultMeshClient? _meshClient; private bool _liveDocumentsReady; private CultCache? _contentState; - private CultMeshContentTransferService? _contentTransfer; - private CultMeshVerifiedBodyMappingBroker? _assetBodyMappings; + private AssetGeneration _assetGeneration; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; private EveSurfaceDocument? _baseSurface; @@ -71,8 +67,19 @@ namespace GameCult.Eve.UnityScene private CultMeshMappedFrameBodyCursor? _mappedEntityFrameCursor; private CultMeshBodyDescriptor? _mappedEntityFrameContract; private CancellationTokenSource? _realtimeLifetime; + private CancellationTokenSource? _surfaceAssetCandidateLifetime; private Task? _realtimePump; private CultMeshRealtimeSession? _realtimeSession; + private EveUnityCultMeshCommandOutbox? _commandOutbox; + private PendingAssetCatalogUpdate? _pendingAssetCatalog; + private bool _assetCatalogUpdateRunning; + private long _surfaceAssetGeneration; + private long _highestObservedBaseSurfaceVersion = -1; + private long _activeBaseSurfaceCandidateVersion = -1; + private long _activeBaseSurfaceCandidateGeneration = -1; + private int _lastAssetPresentationAttemptCount; + private long _mountedBaseSurfaceVersion; + private long _presentationBarrierVersion = -1; private long _lastQueuedEntityViewEpoch = -1; private long _lastQueuedEntityViewSequence = -1; private long _lastPresentedEntityViewEpoch = -1; @@ -83,11 +90,14 @@ namespace GameCult.Eve.UnityScene public EveUnityCultMeshLiveProviderTransport( string cachePath, string rendezvousEndpoint, - string endpointId, + string verseId, + string authorityRuntimeId, string providerId, string surfaceId, string runtimeId = "eve-unity", - CultMeshBodyPublicationResolver? bodyResolver = null) + CultMeshBodyPublicationResolver? bodyResolver = null, + CultMeshAuthorityTrustPolicy? authorityTrust = null, + CultMeshAuthorityTrustPolicy? crossTargetAuthorityTrust = null) { _cachePath = string.IsNullOrWhiteSpace(cachePath) ? throw new ArgumentException("Cache path must be non-empty.", nameof(cachePath)) @@ -95,17 +105,23 @@ 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(); + _assetGeneration = new AssetGeneration( + new AssetSource(_target, _providerId, "", Array.Empty()), + sourceIdentity: "", + catalogVersion: -1, + meshClient: null); _surfaceId = string.IsNullOrWhiteSpace(surfaceId) ? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId)) : surfaceId.Trim(); _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); _bodyResolver = bodyResolver; + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + _crossTargetAuthorityTrust = crossTargetAuthorityTrust ?? _authorityTrust; CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); CurrentInputCapability = new EveInputCapabilityDocument(); @@ -123,7 +139,7 @@ namespace GameCult.Eve.UnityScene public EveInputCapabilityDocument CurrentInputCapability { get; private set; } - public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind { get; private set; } + public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind => _assetGeneration.BodyTransportKind; public event Action? SurfaceDocumentAvailable; @@ -148,8 +164,7 @@ namespace GameCult.Eve.UnityScene public void Connect() { - EnsureOpen(); - BootstrapIfRequired(); + EnsurePrepared(); } public void Disconnect() @@ -158,42 +173,48 @@ 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 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) { throw new InvalidOperationException( - $"CultMesh provider '{_providerId}' bootstrap through stable endpoint '{_endpointId}' failed.", + $"CultMesh provider '{_providerId}' bootstrap through target '{_target}' failed.", error); } } + 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)) @@ -235,7 +256,7 @@ namespace GameCult.Eve.UnityScene else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; else if (document is EveAssetCatalogDocument assetCatalog) - PublishAssetCatalog(assetCatalog); + QueueAssetCatalogUpdate(CurrentAssetSourceIdentity, assetCatalog); else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); else if (document is Exception error) @@ -341,8 +362,8 @@ namespace GameCult.Eve.UnityScene public void SubmitCommand(EveSurfaceCommandRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); - EnsureOpen(); - ResolveAdvertisement(); + EnsurePrepared(); + EnsurePresentationWritable(); var interaction = RequireWorldInteraction(); if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef)) throw new InvalidOperationException("The provider advertisement does not publish a command record reference."); @@ -356,52 +377,79 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(commandId)) throw new InvalidOperationException("Eve command invocations require an idempotency key."); - _pendingCommandIds.Add(commandId); - try + lock (_pendingCommands) { - var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); - _meshClient!.SubmitDocumentAsync( - _endpointId, - recordKey, - request, - _runtimeId, - "eve-unity") - .GetAwaiter() - .GetResult(); + if (_pendingCommands.ContainsKey(commandId)) + throw new InvalidOperationException($"Eve command '{commandId}' is already pending."); + _pendingCommands.Add(commandId, request); } + try { _commandOutbox!.Enqueue(request); } catch { - _pendingCommandIds.Remove(commandId); + ForgetPendingCommand(commandId); throw; } } + 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); + await _meshClient!.SubmitDocumentAsync( + _target, + recordKey, + request, + _runtimeId, + "eve-unity", + cancellationToken) + .ConfigureAwait(false); + } + + 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) + { + Task? subscription = null; + lock (_pendingCommands) + { + _pendingCommands.Remove(commandId); + if (_receiptSubscriptions.TryGetValue(commandId, out subscription)) + _receiptSubscriptions.Remove(commandId); + } + if (subscription != null) DisposeReceiptSubscription(subscription); + } + public void Dispose() { _disposed = true; - foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); - _assetBundles.Clear(); - foreach (var lease in _assetBodyLeases) lease.Dispose(); - _assetBodyLeases.Clear(); - _prefabs.Clear(); - _nativeAssets.Clear(); - _nativeAssetMetadata.Clear(); - _assetSelections.Clear(); - _bundleSelections.Clear(); - _bundleVariants.Clear(); - _loadedBundleUris.Clear(); - _loadingBundleUris.Clear(); + _lifetime.Cancel(); + _commandOutbox?.Dispose(); + _commandOutbox = null; + _assetGeneration.Dispose(); _mappedEntityFrameCursor?.Dispose(); _mappedEntityFrameCursor = null; _mappedEntityFrameContract = null; _realtimeLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Dispose(); + _surfaceAssetCandidateLifetime = null; _realtimeSession?.Dispose(); _realtimeSession = null; - if (_realtimePump != null) - { - try { _realtimePump.GetAwaiter().GetResult(); } - catch (OperationCanceledException) { } - } _realtimePump = null; _realtimeLifetime?.Dispose(); _realtimeLifetime = null; @@ -410,50 +458,82 @@ namespace GameCult.Eve.UnityScene _contentState = null; _meshClient?.Dispose(); _meshClient = null; - _contentTransfer = null; - _assetBodyMappings = null; _baseSurface = null; _embeddedSurfaces.Clear(); - _pendingCommandIds.Clear(); - _publishedReceiptIds.Clear(); + lock (_pendingCommands) + { + _pendingCommands.Clear(); + foreach (var subscription in _receiptSubscriptions.Values) + DisposeReceiptSubscription(subscription); + _receiptSubscriptions.Clear(); + } + lock (_presentationFinalityGate) + { + _deferredTerminalReceipts.Clear(); + _presentationBarrierVersion = -1; + } + _lifetime.Dispose(); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { if (asset == null) throw new ArgumentNullException(nameof(asset)); - EnsureAssetLoaded(asset.AssetRef); - return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; + EnsureAssetPrepared(asset.AssetRef); + return _assetGeneration.Prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; } public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType) { if (asset == null) throw new ArgumentNullException(nameof(asset)); if (assetType == null) throw new ArgumentNullException(nameof(assetType)); - EnsureAssetLoaded(asset.AssetRef); - return _nativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) + EnsureAssetPrepared(asset.AssetRef); + return _assetGeneration.NativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) ? value : null; } + private void EnsureAssetPrepared(string assetRef) + { + EnsurePrepared(); + if (_assetGeneration.AssetSelections.ContainsKey(assetRef) && + !_assetGeneration.NativeAssets.ContainsKey(assetRef)) + throw new InvalidOperationException( + $"Provider asset '{assetRef}' was not materialized during asynchronous preparation."); + } + public bool TryResolveAssetMetadata( EveUnityPlayableWorldAssetBinding asset, out IReadOnlyDictionary metadata) { if (asset == null) throw new ArgumentNullException(nameof(asset)); - return _nativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!); + return _assetGeneration.NativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!); } public bool TryGetRenderChannelLayer(string channel, out int layer) { - return _renderChannelLayers.TryGetValue(channel ?? "", out layer); + return _assetGeneration.RenderChannelLayers.TryGetValue(channel ?? "", out layer); } private void EnsureOpen() { if (_meshClient != null) return; - _meshClient = new CultMeshClient(new CultMeshClientOptions + _meshClient = CreateMeshClient(new[] { _rendezvousEndpoint }, _authorityTrust); + } + + private CultMeshClient CreateMeshClient( + IReadOnlyList rendezvousEndpoints, + CultMeshAuthorityTrustPolicy authorityTrust) + { + return new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = new[] { _rendezvousEndpoint }, + RendezvousEndpoints = rendezvousEndpoints, + Discovery = EveUnityCultMeshConnectivity.Discovery(), + Sessions = new CultMeshSessionManagerOptions + { + Trust = authorityTrust ?? throw new ArgumentNullException(nameof(authorityTrust)) + }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors(), RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] { new CultMeshNativeQuicRealtimeTransportConnector() @@ -461,15 +541,20 @@ namespace GameCult.Eve.UnityScene }); } - private void ResolveAdvertisement(bool forceRefresh = false) + private CultMeshClient AssetMeshClient(AssetGeneration? generation = null) => + (generation ?? _assetGeneration).MeshClient ?? + _meshClient ?? throw new InvalidOperationException("The CultMesh client is not open."); + + private async Task ResolveAdvertisementAsync( + bool forceRefresh = false, + CancellationToken cancellationToken = default) { if (!forceRefresh && _advertisement != null && _advertisedSurface != null) return; - using var advertisements = _meshClient! - .LeaseCollectionAsync(_endpointId) - .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 => @@ -490,7 +575,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[] @@ -536,7 +621,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}'."); @@ -604,19 +689,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); @@ -820,19 +906,78 @@ namespace GameCult.Eve.UnityScene DateTimeOffset now) => publication.LivenessExpiresAtUnixMs > now.ToUnixTimeMilliseconds(); - private void RefreshSurface() + private async Task RefreshSurfaceAsync(CancellationToken cancellationToken) { - var surface = _meshClient! - .ReadAsync(_endpointId, _advertisedSurface!.RecordRef) - .GetAwaiter() - .GetResult(); + var surface = await _meshClient! + .ReadAsync(_target, _advertisedSurface!.RecordRef, cancellationToken); PublishBaseSurface(surface); } private void PublishBaseSurface(EveSurfaceDocument surface) { - _baseSurface = surface ?? throw new ArgumentNullException(nameof(surface)); - PublishComposedSurface(); + if (surface == null) throw new ArgumentNullException(nameof(surface)); + if (!TryBeginBaseSurfaceCandidate(surface.Version, out var generation)) + return; + CancellationToken candidateToken; + lock (_presentationFinalityGate) + { + _surfaceAssetCandidateLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Dispose(); + _surfaceAssetCandidateLifetime = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + candidateToken = _surfaceAssetCandidateLifetime.Token; + } + if (!_bootstrapped) + { + _baseSurface = surface; + PublishComposedSurface(surface.Version); + return; + } + + var source = ResolveAssetSource(surface); + if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal) && + (source.RequiredCatalogVersion <= 0 || source.RequiredCatalogVersion == CurrentAssetCatalogVersion)) + { + _baseSurface = surface; + PublishComposedSurface(surface.Version); + return; + } + + // A surface and its provider-qualified asset catalog are one presentation + // candidate. Keep the previous surface mounted until the candidate assets + // have resolved and preloaded; never lower a remote Verse surface against + // the previous Verse's catalog merely because the surface record arrived first. + BeginPresentationTransition(surface.Version); + _ = RefreshAssetsForSurfaceAsync(surface, source, generation, candidateToken); + } + + private bool TryBeginBaseSurfaceCandidate(long version, out long generation) + { + lock (_presentationFinalityGate) + { + generation = -1; + if (version < _highestObservedBaseSurfaceVersion || + version <= _mountedBaseSurfaceVersion || + (version == _highestObservedBaseSurfaceVersion && + _activeBaseSurfaceCandidateVersion == version)) + return false; + _highestObservedBaseSurfaceVersion = Math.Max(_highestObservedBaseSurfaceVersion, version); + generation = Interlocked.Increment(ref _surfaceAssetGeneration); + _activeBaseSurfaceCandidateVersion = version; + _activeBaseSurfaceCandidateGeneration = generation; + return true; + } + } + + private void RetireFailedBaseSurfaceCandidate(long version, long generation) + { + lock (_presentationFinalityGate) + { + if (_activeBaseSurfaceCandidateVersion != version || + _activeBaseSurfaceCandidateGeneration != generation) + return; + _activeBaseSurfaceCandidateVersion = -1; + _activeBaseSurfaceCandidateGeneration = -1; + } } private void PublishEmbeddedSurface(string recordKey, EveSurfaceDocument surface) @@ -843,13 +988,18 @@ namespace GameCult.Eve.UnityScene PublishComposedSurface(); } - private void PublishComposedSurface() + private void PublishComposedSurface(long? committedBaseVersion = null) { - var surface = ComposeSurface( - _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable."), - _embeddedSurfaces); + ApplyPreparedSurface(PrepareComposedSurface( + _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable.")), + committedBaseVersion); + } + + private PreparedSurface PrepareComposedSurface(EveSurfaceDocument baseSurface) + { + var surface = ComposeSurface(baseSurface, _embeddedSurfaces); var interaction = RequireWorldInteraction(); - CurrentSurfaceDocument = new EveUnitySceneProviderSurfaceDocument( + var document = new EveUnitySceneProviderSurfaceDocument( surface, new EveUnitySceneProviderSurfaceAdvertisement( _advertisedSurface.SurfaceId, @@ -861,7 +1011,25 @@ namespace GameCult.Eve.UnityScene interaction.Ownership)), _advertisedSurface.RecordRef, surface.Version); + var lowered = new EveUnitySceneSurfaceLowerer() + .Lower(document.SurfaceDocument, document.AdvertisedSurface); + var continuousCommands = new HashSet(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.MovementCommand)) + continuousCommands.Add(lowered.PlayableWorld.MovementCommand); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.LookCommand)) + continuousCommands.Add(lowered.PlayableWorld.LookCommand); + return new PreparedSurface(document, continuousCommands); + } + + private void ApplyPreparedSurface(PreparedSurface prepared, long? committedBaseVersion = null) + { + CurrentSurfaceDocument = prepared.Document; + _continuousCommandIds.Clear(); + foreach (var command in prepared.ContinuousCommands) + _continuousCommandIds.Add(command); SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); + if (committedBaseVersion.HasValue) + CompletePresentationTransition(committedBaseVersion.Value); } internal static EveSurfaceDocument ComposeSurface( @@ -908,13 +1076,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 +1092,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 +1115,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,30 +1141,108 @@ 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)); - } - var assetCatalogRef = RequireWorldInteraction().AssetManifestRecordRef; - if (!string.IsNullOrWhiteSpace(assetCatalogRef)) - { - LeaseDocument( - assetCatalogRef, document => _liveDocuments.Enqueue(document), - publishInitial: false); + cancellationToken); } - LeaseCollection( - receipt => _liveDocuments.Enqueue(receipt), - 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 (_pendingCommands) + { + 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 (_pendingCommands) + { + 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) return; + lock (_pendingCommands) + { + if (!_pendingCommands.TryGetValue(expectedCommandId, out var request) || + !ReceiptMatches(request, receipt)) + 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() @@ -1004,29 +1254,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(_endpointId, 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(_endpointId, 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 => { @@ -1055,44 +1308,341 @@ namespace GameCult.Eve.UnityScene public EveSurfaceDocument Document { get; } } + private sealed class PreparedSurface + { + public PreparedSurface( + EveUnitySceneProviderSurfaceDocument document, + IReadOnlyCollection continuousCommands) + { + Document = document; + ContinuousCommands = continuousCommands; + } + + public EveUnitySceneProviderSurfaceDocument Document { get; } + public IReadOnlyCollection ContinuousCommands { get; } + } + private static void TraceHotState(string message) { if (string.Equals(Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", StringComparison.Ordinal)) Debug.Log($"EveUnity CultMesh hot state: {message}"); } - private void RefreshAssetCatalog() + private async Task RefreshAssetCatalogAsync(CancellationToken cancellationToken) { - var interaction = RequireWorldInteraction(); - if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) + var source = ResolveAssetSource( + _baseSurface ?? throw new InvalidOperationException("The provider surface must be available before its assets are resolved.")); + if (string.IsNullOrWhiteSpace(source.ManifestRecordRef)) return; - var catalog = _meshClient! - .ReadAsync(_endpointId, interaction.AssetManifestRecordRef) - .GetAwaiter() - .GetResult(); - PublishAssetCatalog(catalog); + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); + CommitAssetGeneration(candidate); } - private void PublishAssetCatalog(EveAssetCatalogDocument catalog) + private async Task RefreshAssetsForSurfaceAsync( + EveSurfaceDocument surface, + AssetSource source, + long generation, + CancellationToken cancellationToken) { - if (catalog.Version == CurrentAssetCatalogVersion) - return; + Exception? finalError = null; + Interlocked.Exchange(ref _lastAssetPresentationAttemptCount, 0); + for (var attempt = 1; attempt <= AssetPresentationAttemptLimit; attempt++) + { + Interlocked.Exchange(ref _lastAssetPresentationAttemptCount, attempt); + try + { + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + await _assetGenerationGate.WaitAsync(cancellationToken); + try + { + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + { + candidate.Dispose(); + return; + } + CommitAssetGeneration(candidate, surface); + return; + } + finally + { + _assetGenerationGate.Release(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + finalError = error; + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + if (attempt < AssetPresentationAttemptLimit) + { + try + { + await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + } + } + } + RetireFailedBaseSurfaceCandidate(surface.Version, generation); + _liveDocuments.Enqueue(finalError ?? new InvalidOperationException( + $"Eve surface {surface.Version} asset preparation exhausted its retry policy.")); + } - foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); - _assetBundles.Clear(); - foreach (var lease in _assetBodyLeases) lease.Dispose(); - _assetBodyLeases.Clear(); - CurrentAssetBodyTransportKind = null; - _prefabs.Clear(); - _nativeAssets.Clear(); - _nativeAssetMetadata.Clear(); - _assetSelections.Clear(); - _bundleSelections.Clear(); - _bundleVariants.Clear(); - _loadedBundleUris.Clear(); - _loadingBundleUris.Clear(); - _renderChannelLayers.Clear(); + private AssetSource ResolveAssetSource(EveSurfaceDocument surface) + { + var world = FindWorldScene(surface.Surface.Root); + var manifestRecordRef = world?.GetProp("assetManifest") ?? ""; + if (string.IsNullOrWhiteSpace(manifestRecordRef)) + manifestRecordRef = RequireWorldInteraction().AssetManifestRecordRef; + var providerId = world?.GetProp("assetProviderId") ?? ""; + var verseId = world?.GetProp("assetVerseId") ?? ""; + var authorityRuntimeId = world?.GetProp("assetAuthorityRuntimeId") ?? ""; + var requiredCatalogVersion = long.TryParse( + world?.GetProp("assetCatalogVersion"), + out var parsedCatalogVersion) + ? Math.Max(0, parsedCatalogVersion) + : 0; + var rendezvousEndpoints = ParseEndpointList(world?.GetProp("assetRendezvousEndpoints") ?? ""); + var hasQualifiedSource = !string.IsNullOrWhiteSpace(providerId) || + !string.IsNullOrWhiteSpace(verseId) || + !string.IsNullOrWhiteSpace(authorityRuntimeId); + if (!hasQualifiedSource) + return new AssetSource( + _target, + _providerId, + manifestRecordRef, + Array.Empty(), + requiredCatalogVersion); + if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(verseId) || + string.IsNullOrWhiteSpace(authorityRuntimeId)) + throw new InvalidOperationException( + "A provider-qualified Eve asset source requires assetProviderId, assetVerseId, and assetAuthorityRuntimeId."); + return new AssetSource( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + providerId, + manifestRecordRef, + rendezvousEndpoints, + requiredCatalogVersion); + } + + private async Task BuildAssetGenerationAsync( + AssetSource source, + CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var crossTarget = IsCrossTarget(source); + var endpoints = AssetRendezvousEndpoints(source, crossTarget); + var candidate = new AssetGeneration( + source, + source.Identity, + catalogVersion: -1, + crossTarget || source.RendezvousEndpoints.Count > 0 + ? CreateMeshClient( + endpoints, + AssetAuthorityTrust(source)) + : null); + try + { + var catalog = await AttachAssetCatalogSubscriptionAsync( + candidate, + source.RequiredCatalogVersion, + cancellationToken); + ConfigureAssetCatalog(candidate, catalog); + ReuseCompatibleBundles(_assetGeneration, candidate); + await PreloadAssetsAsync(candidate, cancellationToken); + if (source.RequiredCatalogVersion > 0) + return candidate; + var latest = await candidate.CatalogLease!.Handle.LatestAsync(); + candidate.ObserveCatalog(latest); + if (latest.Version != candidate.CatalogVersion) + { + candidate.Dispose(); + continue; + } + return candidate; + } + catch + { + candidate.Dispose(); + throw; + } + } + } + + private bool IsCrossTarget(AssetSource source) => + !string.Equals(source.Target.VerseId, _target.VerseId, StringComparison.Ordinal) || + !string.Equals(source.Target.AuthorityRuntimeId, _target.AuthorityRuntimeId, StringComparison.Ordinal); + + private CultMeshAuthorityTrustPolicy AssetAuthorityTrust(AssetSource source) => + IsCrossTarget(source) ? _crossTargetAuthorityTrust : _authorityTrust; + + private IReadOnlyList AssetRendezvousEndpoints(AssetSource source, bool crossTarget) + { + if (source.RendezvousEndpoints.Count > 0) + return source.RendezvousEndpoints; + if (crossTarget) + throw new InvalidOperationException( + $"Cross-target asset source '{source.Target.VerseId}/{source.Target.AuthorityRuntimeId}' " + + "must advertise at least one Odin rendezvous endpoint."); + return new[] { _rendezvousEndpoint }; + } + + private async Task AttachAssetCatalogSubscriptionAsync( + AssetGeneration candidate, + long requiredCatalogVersion, + CancellationToken cancellationToken) + { + var lease = await AssetMeshClient(candidate) + .LeaseDocumentAsync( + candidate.Source.Target, + candidate.Source.ManifestRecordRef, + cancellationToken); + IDisposable? watch = null; + try + { + var catalog = await lease.Handle.LatestAsync(); + candidate.ObserveCatalog(catalog); + if (requiredCatalogVersion > 0) + { + RequireCatalogVersion(catalog, requiredCatalogVersion, candidate.Source.ManifestRecordRef); + candidate.CatalogLease = lease; + return catalog; + } + + watch = lease.Handle.Watch(observedCatalog => + { + var pending = candidate.ObserveCatalog(observedCatalog); + if (pending != null) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pending); + }); + candidate.CatalogWatch = watch; + candidate.CatalogLease = lease; + return catalog; + } + catch + { + watch?.Dispose(); + lease.Dispose(); + throw; + } + } + + private static void RequireCatalogVersion( + EveAssetCatalogDocument catalog, + long requiredCatalogVersion, + string manifestRecordRef) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + if (requiredCatalogVersion <= 0 || catalog.Version == requiredCatalogVersion) + return; + throw new InvalidDataException( + $"Asset catalog record '{manifestRecordRef}' contains version {catalog.Version}, " + + $"but the Eve surface requires immutable version {requiredCatalogVersion}."); + } + + private void CommitAssetGeneration(AssetGeneration candidate, EveSurfaceDocument? surface = null) + { + PreparedSurface prepared; + try + { + prepared = PrepareComposedSurface(surface ?? _baseSurface ?? + throw new InvalidOperationException("The base Eve surface is unavailable.")); + } + catch + { + candidate.Dispose(); + throw; + } + var previous = _assetGeneration; + previous.TransferSharedBundlesTo(candidate); + _assetGeneration = candidate; + var pendingCatalog = candidate.ActivateCatalogObservation(); + if (pendingCatalog != null && candidate.Source.RequiredCatalogVersion <= 0) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pendingCatalog); + if (surface != null) + _baseSurface = surface; + try + { + ApplyPreparedSurface(prepared, surface?.Version); + } + finally + { + previous.Dispose(); + } + } + + private static void ReuseCompatibleBundles(AssetGeneration current, AssetGeneration candidate) + { + foreach (var pair in candidate.BundleVariants) + { + if (!current.LoadedBundlesByUri.TryGetValue(pair.Key, out var bundle) || + !current.BundleVariants.TryGetValue(pair.Key, out var existing) || + !string.Equals(existing.ContentHash, pair.Value.ContentHash, StringComparison.Ordinal) || + existing.SizeBytes != pair.Value.SizeBytes) + continue; + var selections = candidate.BundleSelections[pair.Key]; + if (selections.Any(selection => + !current.NativeAssets.ContainsKey(selection.Asset.AssetRef))) + continue; + + candidate.AssetBundles.Add(bundle); + candidate.BorrowedAssetBundles.Add(bundle); + candidate.LoadedBundlesByUri[pair.Key] = bundle; + candidate.LoadedBundleUris.Add(pair.Key); + foreach (var selection in selections) + { + var value = current.NativeAssets[selection.Asset.AssetRef]; + candidate.NativeAssets[selection.Asset.AssetRef] = value; + if (value is GameObject prefab) + candidate.Prefabs[selection.Asset.AssetRef] = prefab; + if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && + !string.IsNullOrWhiteSpace(role)) + { + candidate.NativeAssets[role] = value; + if (value is GameObject rolePrefab) + candidate.Prefabs[role] = rolePrefab; + } + } + } + } + + private static EveSurfaceComponent? FindWorldScene(EveSurfaceComponent component) + { + if (string.Equals(component.Kind, "world.scene3d", StringComparison.Ordinal) || + string.Equals(component.Kind, "world", StringComparison.Ordinal)) + return component; + foreach (var child in component.Children ?? Array.Empty()) + { + var found = FindWorldScene(child); + if (found != null) return found; + } + return null; + } + + private static IReadOnlyList ParseEndpointList(string value) => + (value ?? "") + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(endpoint => endpoint.Trim()) + .Where(endpoint => endpoint.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private static void ConfigureAssetCatalog(AssetGeneration candidate, EveAssetCatalogDocument catalog) + { var selected = catalog.Assets .Select(asset => new SelectedAsset( asset, @@ -1111,96 +1661,178 @@ namespace GameCult.Eve.UnityScene $"Provider asset catalog '{catalog.CatalogId}' has no unity-scene/{CurrentBundlePlatform()} variant. " + $"Advertised variants: {string.Join(", ", advertisedVariants)}"); } - ReadCameraPolicies(selected.Select(selection => selection.Variant!)); + candidate.SetCatalogVersion(catalog.Version); + ReadCameraPolicies(candidate, selected.Select(selection => selection.Variant!)); foreach (var selection in selected) { var variant = selection.Variant!; - _assetSelections[selection.Asset.AssetRef] = selection; - _bundleVariants[variant.Uri] = variant; - if (!_bundleSelections.TryGetValue(variant.Uri, out var group)) + candidate.AssetSelections[selection.Asset.AssetRef] = selection; + candidate.BundleVariants[variant.Uri] = variant; + if (!candidate.BundleSelections.TryGetValue(variant.Uri, out var group)) { group = new List(); - _bundleSelections[variant.Uri] = group; + candidate.BundleSelections[variant.Uri] = group; } group.Add(selection); var metadata = MergeAssetMetadata(selection.Asset.Metadata, variant.Metadata); - _nativeAssetMetadata[selection.Asset.AssetRef] = metadata; + candidate.NativeAssetMetadata[selection.Asset.AssetRef] = metadata; if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && !string.IsNullOrWhiteSpace(role)) { - _assetSelections[role] = selection; - _nativeAssetMetadata[role] = metadata; + candidate.AssetSelections[role] = selection; + candidate.NativeAssetMetadata[role] = metadata; } } - - CurrentAssetCatalogVersion = catalog.Version; } - private void EnsureAssetLoaded(string assetRef) + private void QueueAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog) { - if (_nativeAssets.ContainsKey(assetRef) || !_assetSelections.TryGetValue(assetRef, out var selection)) + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + if (_assetGeneration.Source.RequiredCatalogVersion > 0) + { + _pendingAssetCatalog = null; return; - EnsureBundleLoaded(selection.Variant!.Uri); + } + _pendingAssetCatalog = new PendingAssetCatalogUpdate( + sourceIdentity, + catalog); + if (_assetCatalogUpdateRunning) return; + _assetCatalogUpdateRunning = true; + _ = DrainAssetCatalogUpdatesAsync(); } - private void EnsureBundleLoaded(string uri) + private async Task DrainAssetCatalogUpdatesAsync() { - if (_loadedBundleUris.Contains(uri)) return; - if (!_bundleVariants.TryGetValue(uri, out var variant)) + try + { + while (!_lifetime.IsCancellationRequested) + { + var update = _pendingAssetCatalog; + _pendingAssetCatalog = null; + if (update == null) break; + if (_assetGeneration.Source.RequiredCatalogVersion > 0) + continue; + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal)) + continue; + if (update.Catalog.Version == CurrentAssetCatalogVersion) + continue; + await _assetGenerationGate.WaitAsync(_lifetime.Token); + try + { + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) || + update.Catalog.Version == CurrentAssetCatalogVersion || + _assetGeneration.Source.RequiredCatalogVersion > 0) + continue; + var currentSource = _assetGeneration.Source; + var source = new AssetSource( + currentSource.Target, + currentSource.ProviderId, + currentSource.ManifestRecordRef, + currentSource.RendezvousEndpoints, + 0); + var candidate = await BuildAssetGenerationAsync(source, _lifetime.Token); + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) || + _assetGeneration.Source.RequiredCatalogVersion > 0) + { + candidate.Dispose(); + continue; + } + CommitAssetGeneration(candidate); + } + finally + { + _assetGenerationGate.Release(); + } + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (Exception error) + { + _liveDocuments.Enqueue(error); + } + finally + { + _assetCatalogUpdateRunning = false; + if (_pendingAssetCatalog != null && !_lifetime.IsCancellationRequested) + QueueAssetCatalogUpdate(_pendingAssetCatalog.SourceIdentity, _pendingAssetCatalog.Catalog); + } + } + + private async Task PreloadAssetsAsync(AssetGeneration candidate, CancellationToken cancellationToken) + { + foreach (var uri in candidate.BundleVariants.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray()) + await EnsureBundleLoadedAsync(candidate, uri, cancellationToken); + } + + private async Task EnsureBundleLoadedAsync( + AssetGeneration candidate, + string uri, + CancellationToken cancellationToken) + { + if (candidate.LoadedBundleUris.Contains(uri)) return; + if (!candidate.BundleVariants.TryGetValue(uri, out var variant)) throw new InvalidOperationException($"Provider asset bundle '{uri}' is not present in the selected runtime catalog."); - if (!_loadingBundleUris.Add(uri)) + if (!candidate.LoadingBundleUris.Add(uri)) throw new InvalidOperationException($"Provider asset bundle dependency cycle includes '{uri}'."); try { if (variant.Metadata.TryGetValue("unity.bundleDependencyUris", out var dependencies)) foreach (var dependency in dependencies.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) - EnsureBundleLoaded(dependency); + await EnsureBundleLoadedAsync(candidate, dependency, cancellationToken); var groupElapsed = Stopwatch.StartNew(); - var descriptor = _meshClient!.ReadAsync(_endpointId, uri) - .GetAwaiter() - .GetResult(); + var descriptor = await AssetMeshClient(candidate) + .ReadAsync(candidate.Source.Target, uri, cancellationToken); TraceStartup("asset-manifest", groupElapsed); - var bundle = LoadVerifiedBundle(descriptor, variant); + var bundle = await LoadVerifiedBundleAsync(candidate, descriptor, variant, cancellationToken); TraceStartup("asset-transfer-and-bundle-load", groupElapsed); if (bundle == null) throw new InvalidOperationException($"Unity could not load provider asset bundle '{uri}'."); - _assetBundles.Add(bundle); + candidate.AssetBundles.Add(bundle); + candidate.LoadedBundlesByUri[uri] = bundle; var bundleAssetNames = bundle.GetAllAssetNames(); - foreach (var selection in _bundleSelections[uri]) + foreach (var selection in candidate.BundleSelections[uri]) { 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 " + $"'{selection.Variant.AssetKey}'. Available assets: " + string.Join(", ", bundleAssetNames)); - _nativeAssets[selection.Asset.AssetRef] = value; + candidate.NativeAssets[selection.Asset.AssetRef] = value; if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && !string.IsNullOrWhiteSpace(role)) - _nativeAssets[role] = value; + candidate.NativeAssets[role] = value; if (value is GameObject prefab) { - _prefabs[selection.Asset.AssetRef] = prefab; - if (!string.IsNullOrWhiteSpace(role)) _prefabs[role] = prefab; + candidate.Prefabs[selection.Asset.AssetRef] = prefab; + if (!string.IsNullOrWhiteSpace(role)) candidate.Prefabs[role] = prefab; } } - _loadedBundleUris.Add(uri); + candidate.LoadedBundleUris.Add(uri); TraceStartup("bundle-assets", groupElapsed); } finally { - _loadingBundleUris.Remove(uri); + candidate.LoadingBundleUris.Remove(uri); } } - 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 @@ -1215,6 +1847,115 @@ namespace GameCult.Eve.UnityScene public EveAssetVariant? Variant { get; } } + private sealed class AssetGeneration : IDisposable + { + private readonly object _catalogObservationGate = new object(); + private EveAssetCatalogDocument? _latestObservedCatalog; + private bool _catalogObservationActive; + + public AssetGeneration( + AssetSource source, + string sourceIdentity, + long catalogVersion, + CultMeshClient? meshClient) + { + Source = source; + SourceIdentity = sourceIdentity ?? ""; + CatalogVersion = catalogVersion; + MeshClient = meshClient; + } + + public AssetSource Source { get; } + public string SourceIdentity { get; } + public long CatalogVersion { get; private set; } + public CultMeshClient? MeshClient { get; } + public CultMeshDocumentLease? CatalogLease { get; set; } + public IDisposable? CatalogWatch { get; set; } + public CultMeshContentTransferService? ContentTransfer { get; set; } + public CultMeshVerifiedBodyMappingBroker? BodyMappings { get; set; } + public CultMeshBodyTransportKind? BodyTransportKind { get; set; } + public Dictionary Prefabs { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary NativeAssets { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary> NativeAssetMetadata { get; } = + new Dictionary>(StringComparer.Ordinal); + public List AssetBundles { get; } = new List(); + public HashSet BorrowedAssetBundles { get; } = new HashSet(); + public Dictionary LoadedBundlesByUri { get; } = + new Dictionary(StringComparer.Ordinal); + public Dictionary AssetSelections { get; } = + new Dictionary(StringComparer.Ordinal); + public Dictionary> BundleSelections { get; } = + new Dictionary>(StringComparer.Ordinal); + public Dictionary BundleVariants { get; } = + new Dictionary(StringComparer.Ordinal); + public HashSet LoadedBundleUris { get; } = new HashSet(StringComparer.Ordinal); + public HashSet LoadingBundleUris { get; } = new HashSet(StringComparer.Ordinal); + public List BodyLeases { get; } = new List(); + public Dictionary RenderChannelLayers { get; } = + new Dictionary(StringComparer.Ordinal); + + public void TransferSharedBundlesTo(AssetGeneration next) + { + foreach (var bundle in next.BorrowedAssetBundles) + AssetBundles.Remove(bundle); + next.BorrowedAssetBundles.Clear(); + } + + public void SetCatalogVersion(long catalogVersion) + { + if (CatalogVersion >= 0 && CatalogVersion != catalogVersion) + throw new InvalidOperationException("An asset generation cannot change its catalog version after configuration."); + CatalogVersion = catalogVersion; + } + + public EveAssetCatalogDocument? ObserveCatalog(EveAssetCatalogDocument catalog) + { + lock (_catalogObservationGate) + { + _latestObservedCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + return _catalogObservationActive && catalog.Version != CatalogVersion ? catalog : null; + } + } + + public EveAssetCatalogDocument? ActivateCatalogObservation() + { + lock (_catalogObservationGate) + { + _catalogObservationActive = true; + return _latestObservedCatalog != null && _latestObservedCatalog.Version != CatalogVersion + ? _latestObservedCatalog + : null; + } + } + + public void Dispose() + { + CatalogWatch?.Dispose(); + CatalogWatch = null; + CatalogLease?.Dispose(); + CatalogLease = null; + MeshClient?.Dispose(); + foreach (var bundle in AssetBundles) + if (!BorrowedAssetBundles.Contains(bundle)) + bundle.Unload(unloadAllLoadedObjects: false); + AssetBundles.Clear(); + BorrowedAssetBundles.Clear(); + foreach (var lease in BodyLeases) + lease.Dispose(); + BodyLeases.Clear(); + Prefabs.Clear(); + NativeAssets.Clear(); + NativeAssetMetadata.Clear(); + AssetSelections.Clear(); + BundleSelections.Clear(); + BundleVariants.Clear(); + LoadedBundleUris.Clear(); + LoadingBundleUris.Clear(); + LoadedBundlesByUri.Clear(); + RenderChannelLayers.Clear(); + } + } + private static string? ResolveBundleAssetName(IEnumerable bundleAssetNames, string advertisedAssetKey) { return bundleAssetNames.FirstOrDefault(assetName => @@ -1231,9 +1972,8 @@ namespace GameCult.Eve.UnityScene return merged; } - private void ReadCameraPolicies(IEnumerable variants) + private static void ReadCameraPolicies(AssetGeneration candidate, IEnumerable variants) { - _renderChannelLayers.Clear(); foreach (var variant in variants) { foreach (var pair in variant.Metadata) @@ -1246,12 +1986,14 @@ namespace GameCult.Eve.UnityScene var channel = pair.Key.Substring(prefix.Length, pair.Key.Length - prefix.Length - suffix.Length); if (!string.IsNullOrWhiteSpace(channel) && int.TryParse(pair.Value, out var layer) && layer >= 0 && layer < 32) - _renderChannelLayers[channel] = layer; + candidate.RenderChannelLayers[channel] = layer; } } } - private long CurrentAssetCatalogVersion { get; set; } = -1; + private long CurrentAssetCatalogVersion => _assetGeneration.CatalogVersion; + + private string CurrentAssetSourceIdentity => _assetGeneration.SourceIdentity; private static string CurrentBundlePlatform() { @@ -1261,33 +2003,38 @@ namespace GameCult.Eve.UnityScene : Application.platform.ToString(); } - private CultMeshContentTransferService ContentTransfer() + private CultMeshContentTransferService ContentTransfer(AssetGeneration candidate) { - ResolveAdvertisement(); - if (_contentTransfer != null) - return _contentTransfer; + if (candidate.ContentTransfer != null) + return candidate.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"); if (string.IsNullOrWhiteSpace(cacheRoot)) cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets"); - _assetBodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); + candidate.BodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); _contentState ??= new CultCache( CultMesh.CreateCultCacheDocumentRegistry(typeof(CultMeshContentTransferStateDocument))); - _contentTransfer = new CultMeshContentTransferService( + candidate.ContentTransfer = new CultMeshContentTransferService( _contentState, new[] { - _meshClient!.ContentProvider( - _advertisement.ProviderId, - _advertisement.ServiceId) + AssetMeshClient(candidate).ContentProvider( + candidate.Source.ProviderId, + candidate.Source.Target) }, new CultMeshContentTransferOptions(cacheRoot), - _assetBodyMappings); - return _contentTransfer; + candidate.BodyMappings); + return candidate.ContentTransfer; } - private AssetBundle? LoadVerifiedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant) + private async Task LoadVerifiedBundleAsync( + AssetGeneration candidate, + CultMeshCdnArtifactManifest manifest, + EveAssetVariant variant, + CancellationToken cancellationToken) { var hash = NormalizeContentHash(variant.ContentHash); if (manifest.SizeBytes != variant.SizeBytes) @@ -1299,12 +2046,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(candidate) + .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, @@ -1318,34 +2063,28 @@ namespace GameCult.Eve.UnityScene var transport = new CultMeshBodyTransportService( new ICultMeshBodyTransportAdapter[] { - new CultMeshMappedBodyAdapter(_assetBodyMappings!), - new CultMeshNetworkBodyAdapter(_ => ReadVerifiedArtifactBytes(manifest)) + new CultMeshMappedBodyAdapter(candidate.BodyMappings!), + 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); - 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 negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, validationRequest); + candidate.BodyTransportKind = negotiated.SelectedTransport; + candidate.BodyLeases.Add(negotiated.Lease); + 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 +2114,103 @@ namespace GameCult.Eve.UnityScene private void PublishReceipt(EveCommandReceiptDocument receipt) { - if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) - return; + var terminal = IsTerminalReceiptState(receipt.State); + lock (_pendingCommands) + { + if (!_pendingCommands.TryGetValue(receipt.CommandId, out var request) || + !ReceiptMatches(request, receipt)) + return; + } + if (terminal) + { + _commandOutbox?.Acknowledge(receipt.CommandId); + if (DeferTerminalReceiptUntilPresentation(receipt)) + return; + } + FinalizeReceipt(receipt, terminal); + } + + private void EnsurePresentationWritable() + { + lock (_presentationFinalityGate) + { + if (_presentationBarrierVersion > _mountedBaseSurfaceVersion) + throw new InvalidOperationException( + $"The Eve presentation is awaiting provider surface version {_presentationBarrierVersion}; " + + $"mounted base version {_mountedBaseSurfaceVersion} is read-only until that generation commits."); + } + } + + private void BeginPresentationTransition(long sourceVersion) + { + lock (_presentationFinalityGate) + { + if (sourceVersion <= _mountedBaseSurfaceVersion) + return; + _presentationBarrierVersion = Math.Max(_presentationBarrierVersion, sourceVersion); + } + } + + private bool DeferTerminalReceiptUntilPresentation(EveCommandReceiptDocument receipt) + { + if (receipt.PresentationSurfaceVersion <= 0) + return false; + lock (_presentationFinalityGate) + { + if (receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion) + return false; + _presentationBarrierVersion = Math.Max( + _presentationBarrierVersion, + receipt.PresentationSurfaceVersion); + _deferredTerminalReceipts[receipt.CommandId] = receipt; + return true; + } + } + + private void CompletePresentationTransition(long committedVersion) + { + EveCommandReceiptDocument[] ready; + lock (_presentationFinalityGate) + { + _mountedBaseSurfaceVersion = Math.Max(_mountedBaseSurfaceVersion, committedVersion); + if (_activeBaseSurfaceCandidateVersion == committedVersion) + { + _activeBaseSurfaceCandidateVersion = -1; + _activeBaseSurfaceCandidateGeneration = -1; + } + ready = _deferredTerminalReceipts.Values + .Where(receipt => receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion) + .ToArray(); + foreach (var receipt in ready) + _deferredTerminalReceipts.Remove(receipt.CommandId); + if (_presentationBarrierVersion <= _mountedBaseSurfaceVersion) + _presentationBarrierVersion = -1; + } + foreach (var receipt in ready) + FinalizeReceipt(receipt, terminal: true); + } + + private long MountedBaseSurfaceVersion + { + get + { + lock (_presentationFinalityGate) + return _mountedBaseSurfaceVersion; + } + } + + private void FinalizeReceipt(EveCommandReceiptDocument receipt, bool terminal) + { + if (terminal) + { + lock (_pendingCommands) + { + if (!_pendingCommands.ContainsKey(receipt.CommandId)) + return; + _pendingCommands.Remove(receipt.CommandId); + } + ForgetPendingCommand(receipt.CommandId); + } CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, @@ -1397,11 +2231,93 @@ namespace GameCult.Eve.UnityScene receipt.Navigation.ProviderId, receipt.Navigation.SurfaceId, receipt.Navigation.SurfaceKind, - receipt.Navigation.RendezvousEndpoints))); - if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) - _pendingCommandIds.Remove(receipt.CommandId); + receipt.Navigation.RendezvousEndpoints, + receipt.Navigation.AuthorityRuntimeId))); + } + + private sealed class AssetSource + { + public AssetSource( + CultMeshSessionTarget target, + string providerId, + string manifestRecordRef, + IReadOnlyList rendezvousEndpoints) + : this(target, providerId, manifestRecordRef, rendezvousEndpoints, 0) + { + } + + public AssetSource( + CultMeshSessionTarget target, + string providerId, + string manifestRecordRef, + IReadOnlyList rendezvousEndpoints, + long requiredCatalogVersion) + { + Target = target; + ProviderId = providerId ?? ""; + ManifestRecordRef = manifestRecordRef ?? ""; + RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty(); + RequiredCatalogVersion = Math.Max(0, requiredCatalogVersion); + Identity = Target.VerseId + "\u001f" + Target.AuthorityRuntimeId + "\u001f" + + ProviderId + "\u001f" + ManifestRecordRef + "\u001f" + + string.Join("\u001e", RendezvousEndpoints); + } + + public CultMeshSessionTarget Target { get; } + public string ProviderId { get; } + public string ManifestRecordRef { get; } + public IReadOnlyList RendezvousEndpoints { get; } + public long RequiredCatalogVersion { get; } + public string Identity { get; } + } + + private sealed class PendingAssetCatalogUpdate + { + public PendingAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog) + { + SourceIdentity = sourceIdentity ?? ""; + Catalog = catalog; + } + + public string SourceIdentity { get; } + public EveAssetCatalogDocument Catalog { get; } + } + + private bool ReceiptMatches(EveSurfaceCommandRequest request, EveCommandReceiptDocument receipt) => + string.Equals(receipt.Schema, EveCommandReceiptDocument.SchemaId, StringComparison.Ordinal) && + string.Equals(receipt.CommandId, request.CommandId, StringComparison.Ordinal) && + string.Equals(receipt.Command, request.Command, StringComparison.Ordinal) && + string.Equals(receipt.ProviderId, request.ProviderId, StringComparison.Ordinal) && + string.Equals(receipt.SurfaceId, request.SurfaceId, StringComparison.Ordinal) && + string.Equals(receipt.Authority, _target.AuthorityRuntimeId, StringComparison.Ordinal) && + string.Equals(receipt.InvocationHash, EveCommandInvocationHash.Compute(request), StringComparison.Ordinal) && + IsKnownReceiptState(receipt.State); + + private static bool IsKnownReceiptState(string state) => + string.Equals(state, "pending", StringComparison.OrdinalIgnoreCase) || + IsTerminalReceiptState(state); + + private static bool IsTerminalReceiptState(string state) => + string.Equals(state, "accepted", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "denied", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "reconciled", StringComparison.OrdinalIgnoreCase); + + 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() 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..3d356f3 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; @@ -39,10 +40,18 @@ namespace GameCult.Eve.UnityScene [FormerlySerializedAs("replicaPath")] [SerializeField] private string cacheDirectory = ""; [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; + private PreviousProvider? _previousProvider; + private bool _navigationRouteCommitted; + private int _connectionLeaseCount; private Task? _preparation; + private CancellationTokenSource? _preparationLifetime; private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); private readonly ConcurrentQueue _pendingFields = new ConcurrentQueue(); @@ -85,7 +94,9 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string verseId = "", string requiredSurfaceKind = "interactive-world", - string clientRuntimeId = "eve-unity") + string clientRuntimeId = "eve-unity", + CultMeshAuthorityTrustPolicy? authorityTrust = null, + CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null) { if (_bridge != null || _preparation != null) throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); @@ -97,11 +108,17 @@ 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); + _navigationAuthorityTrust = navigationAuthorityTrust ?? _authorityTrust; } public void Connect() { + // During navigation the candidate presentation connects before the route + // becomes canonical, so every new lease must connect its selected bridge. Bridge.Connect(); + checked { _connectionLeaseCount++; } } public Task PrepareAsync() @@ -109,13 +126,21 @@ 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; } public void Disconnect() { - _bridge?.Disconnect(); + if (_connectionLeaseCount <= 0) + return; + _connectionLeaseCount--; + if (_connectionLeaseCount == 0) + _bridge?.Disconnect(); } public async Task NavigateAsync(EveUnitySceneNavigationTarget target) @@ -126,29 +151,107 @@ 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; + 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, + target.AuthorityRuntimeId, + _navigationAuthorityTrust, + _navigationAuthorityTrust, + CancellationToken.None).ConfigureAwait(false); + + StagePrepared(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 CommitNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + if (_navigationRouteCommitted) return; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; + AttachPrepared(_transport, _bridge); + _navigationRouteCommitted = true; + } + + public void FinalizeNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + _previousProvider = null; + _navigationRouteCommitted = false; + SafeDispose(previous.Bridge); + SafeDispose(previous.Transport); + } + + public void RollbackNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; + DetachPrepared(_transport, _bridge); + var rejectedBridge = _bridge; + var rejectedTransport = _transport; + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + + Selection = previous.Selection; + _transport = previous.Transport; + _bridge = previous.Bridge; + rendezvousEndpoint = previous.RendezvousEndpoint; + verseFilter = previous.VerseFilter; + providerFilter = previous.ProviderFilter; + surfaceFilter = previous.SurfaceFilter; + surfaceKind = previous.SurfaceKind; + _authorityTrust = previous.AuthorityTrust; + AttachPrepared(_transport, _bridge); + _previousProvider = null; + _navigationRouteCommitted = false; + _preparation = Task.CompletedTask; + SafeDispose(rejectedBridge); + SafeDispose(rejectedTransport); } public void Refresh() { - if (Bridge.IsConnected) - Bridge.Refresh(); + var activeBridge = _navigationRouteCommitted + ? Bridge + : _previousProvider?.Bridge ?? Bridge; + if (activeBridge.IsConnected) + activeBridge.Refresh(); else - Bridge.Connect(); + activeBridge.Connect(); } public void Submit(EveSurfaceCommandRequest request) { - Bridge.Submit(request); + // Navigation preparation is not a command-routing commit. The mounted + // presentation continues to target its old provider until the visual + // candidate has been lowered and accepted by the bootstrap. + (_navigationRouteCommitted ? Bridge : _previousProvider?.Bridge ?? Bridge).Submit(request); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) @@ -184,6 +287,7 @@ namespace GameCult.Eve.UnityScene private void ReleaseTransport() { + _preparationLifetime?.Cancel(); if (_bridge != null) { _bridge.DocumentAvailable -= ForwardDocument; @@ -191,16 +295,24 @@ namespace GameCult.Eve.UnityScene } _bridge?.Dispose(); _transport?.Dispose(); + _previousProvider?.Bridge?.Dispose(); + _previousProvider?.Transport?.Dispose(); + _previousProvider = null; + _navigationRouteCommitted = false; + _connectionLeaseCount = 0; while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); while (_pendingFields.TryDequeue(out _)) { } _bridge = null; _transport = null; _preparation = null; + _preparationLifetime?.Dispose(); + _preparationLifetime = null; Selection = null; } private void Update() { + _previousProvider?.Transport?.PumpLiveEvents(); _transport?.PumpLiveEvents(); EveFieldsSplatsDocument? latestFields = null; while (_pendingFields.TryDequeue(out var fields)) @@ -228,7 +340,7 @@ namespace GameCult.Eve.UnityScene } } - private async Task PrepareTransportAsync() + private async Task PrepareTransportAsync(CancellationToken cancellationToken) { var elapsed = Stopwatch.StartNew(); if (_transport != null) @@ -236,38 +348,183 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync( - rendezvousEndpoint, + var prepared = await PrepareCandidateAsync( + new[] { rendezvousEndpoint }, providerFilter, surfaceFilter, surfaceKind, - verseFilter); - 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( - resolvedCachePath, - Selection.RendezvousEndpoint, - Selection.EndpointId, - Selection.ProviderId, - Selection.SurfaceId, - runtimeId); - _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"); + verseFilter, + "", + _authorityTrust, + _navigationAuthorityTrust, + 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, + string requiredAuthorityRuntimeId, + CultMeshAuthorityTrustPolicy trust, + CultMeshAuthorityTrustPolicy crossTargetTrust, + CancellationToken cancellationToken) + { + var failures = new List(); + foreach (var endpoint in endpoints) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var selection = await new EveUnityCultMeshProviderDiscovery(trust).DiscoverAsync( + endpoint, + requiredProviderId, + requiredSurfaceId, + requiredSurfaceKind, + requiredVerseId, + cancellationToken, + requiredAuthorityRuntimeId).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, + crossTargetAuthorityTrust: crossTargetTrust); + 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}"); + } + } + + throw new InvalidOperationException( + $"No configured Odin endpoint prepared Verse '{requiredVerseId}' surface '{requiredSurfaceId}'. " + + string.Join(" | ", failures)); + } + + private void AdoptPrepared(PreparedProvider prepared) + { + if (_previousProvider != null) + throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first."); + var oldBridge = _bridge; + var oldTransport = _transport; + DetachPrepared(oldTransport, oldBridge); + + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; + AttachPrepared(_transport, _bridge); + + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + oldBridge?.Dispose(); + oldTransport?.Dispose(); + } + + private void StagePrepared(PreparedProvider prepared) + { + if (_previousProvider != null) + { + prepared.Bridge.Dispose(); + prepared.Transport.Dispose(); + throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first."); + } + + _previousProvider = new PreviousProvider( + Selection, + _transport, + _bridge, + rendezvousEndpoint, + verseFilter, + providerFilter, + surfaceFilter, + surfaceKind, + _authorityTrust); + _navigationRouteCommitted = false; + DetachPrepared(_transport, _bridge); + if (_previousProvider.Bridge != null) + _previousProvider.Bridge.ReceiptAvailable += ForwardReceipt; + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + } + + private void AttachPrepared( + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge) + { + if (transport != null) + { + transport.EntityViewAvailable += QueueEntityView; + transport.FieldsSplatsAvailable += QueueFields; + } + if (bridge != null) + { + bridge.DocumentAvailable += ForwardDocument; + bridge.ReceiptAvailable += ForwardReceipt; + } + } + + private void DetachPrepared( + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge) + { + if (transport != null) + { + transport.EntityViewAvailable -= QueueEntityView; + transport.FieldsSplatsAvailable -= QueueFields; + } + if (bridge == null) return; + bridge.DocumentAvailable -= ForwardDocument; + bridge.ReceiptAvailable -= ForwardReceipt; + } + + private void QueueEntityView(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease) => + _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); + + private void QueueFields(EveFieldsSplatsDocument fields) => _pendingFields.Enqueue(fields); + private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) => DocumentAvailable?.Invoke(document); private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) => ReceiptAvailable?.Invoke(receipt); + private static void SafeDispose(IDisposable? disposable) + { + try + { + disposable?.Dispose(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve provider cleanup failed after navigation ownership changed: {error}"); + } + } + private static void TraceStartup(string message) { if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal)) @@ -292,5 +549,57 @@ namespace GameCult.Eve.UnityScene public EveEntitySoaViewDocument Document { get; } public ICultMeshBodyReadLease Lease { get; } } + + private sealed class PreparedProvider + { + public PreparedProvider( + EveUnityCultMeshProviderSelection selection, + EveUnityCultMeshLiveProviderTransport transport, + EveUnitySceneLiveProviderBridge bridge) + { + Selection = selection; + Transport = transport; + Bridge = bridge; + } + + public EveUnityCultMeshProviderSelection Selection { get; } + public EveUnityCultMeshLiveProviderTransport Transport { get; } + public EveUnitySceneLiveProviderBridge Bridge { get; } + } + + private sealed class PreviousProvider + { + public PreviousProvider( + EveUnityCultMeshProviderSelection? selection, + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge, + string rendezvousEndpoint, + string verseFilter, + string providerFilter, + string surfaceFilter, + string surfaceKind, + CultMeshAuthorityTrustPolicy authorityTrust) + { + Selection = selection; + Transport = transport; + Bridge = bridge; + RendezvousEndpoint = rendezvousEndpoint; + VerseFilter = verseFilter; + ProviderFilter = providerFilter; + SurfaceFilter = surfaceFilter; + SurfaceKind = surfaceKind; + AuthorityTrust = authorityTrust; + } + + public EveUnityCultMeshProviderSelection? Selection { get; } + public EveUnityCultMeshLiveProviderTransport? Transport { get; } + public EveUnitySceneLiveProviderBridge? Bridge { get; } + public string RendezvousEndpoint { get; } + public string VerseFilter { get; } + public string ProviderFilter { get; } + public string SurfaceFilter { get; } + public string SurfaceKind { get; } + public CultMeshAuthorityTrustPolicy AuthorityTrust { get; } + } } } 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..7772dbc 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; @@ -14,23 +15,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; } @@ -38,21 +39,22 @@ 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(); + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; + + public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null) + { + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + } public async Task DiscoverAsync( string rendezvousEndpoint, string providerId = "", string surfaceId = "", string surfaceKind = "interactive-world", - string verseId = "") + string verseId = "", + CancellationToken cancellationToken = default, + string requiredAuthorityRuntimeId = "") { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); @@ -60,13 +62,14 @@ namespace GameCult.Eve.UnityScene CultMeshVerseCatalogResponseMessage response; try { - response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync( + response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync( rendezvousEndpoint, new CultMeshVerseCatalogRequestMessage { VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId }, TransportVersion = "cultmesh.v0" }); + cancellationToken.ThrowIfCancellationRequested(); } catch (Exception error) { @@ -91,53 +94,74 @@ namespace GameCult.Eve.UnityScene var observed = new List(); using var mesh = new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = new[] { rendezvousEndpoint } + RendezvousEndpoints = new[] { rendezvousEndpoint }, + Discovery = EveUnityCultMeshConnectivity.Discovery(), + Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors() }); foreach (var candidate in candidates) { - try + foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds( + candidate.AuthorityRuntimeIds, + requiredAuthorityRuntimeId)) { - 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, cancellationToken) + .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}"); + } } } - var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'"; + var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'"; var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}"; var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}"; throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}"); } + internal static IReadOnlyList EligibleAuthorityRuntimeIds( + IEnumerable? authorityRuntimeIds, + string requiredAuthorityRuntimeId) => + (authorityRuntimeIds ?? Array.Empty()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Where(id => string.IsNullOrWhiteSpace(requiredAuthorityRuntimeId) || + string.Equals(id, requiredAuthorityRuntimeId, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef index ba5091b..671a17f 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef @@ -16,7 +16,8 @@ "GameCult.Caching.MessagePack.dll", "GameCult.Mesh.dll", "GameCult.Mesh.Quic.Native.dll", - "GameCult.Networking.dll" + "GameCult.Networking.dll", + "GameCult.Networking.WebSockets.dll" ], "autoReferenced": true, "defineConstraints": [], diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index ffe1791..ea39b34 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -30,8 +30,13 @@ 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; + private StagedPresentation? _ownedPresentation; public void ConfigureProvider(MonoBehaviour providerComponent) { @@ -44,7 +49,12 @@ namespace GameCult.Eve.UnityScene BindNavigation(); } - private void OnDestroy() => UnbindNavigation(); + private void OnDestroy() + { + UnbindNavigation(); + _ownedPresentation?.Dispose(); + _ownedPresentation = null; + } private void BindNavigation() { @@ -77,12 +87,67 @@ namespace GameCult.Eve.UnityScene yield break; _navigationInProgress = true; var navigation = navigable.NavigateAsync(target); + StagedPresentation? staged = null; + PresentationSwap? swap = null; + Transform? previousRoot = null; + var previousRootWasActive = false; while (!navigation.IsCompleted) yield return null; try { navigation.GetAwaiter().GetResult(); - Mount(); + staged = PrepareStagedPresentation(); + previousRoot = sceneRoot; + previousRootWasActive = previousRoot == null || previousRoot.gameObject.activeSelf; + if (previousRoot != null && !ReferenceEquals(previousRoot, transform) && + !ReferenceEquals(previousRoot, staged.SceneRoot)) + previousRoot.gameObject.SetActive(false); + navigable.CommitNavigation(); + swap = ActivateStagedPresentation(staged, previousRootWasActive); + navigable.FinalizeNavigation(); + swap.Complete(); + swap = null; + staged = null; + LastNavigationFailure = null; + } + catch (Exception error) + { + var providerRestored = false; + try + { + navigable.RollbackNavigation(); + providerRestored = true; + } + catch (Exception rollbackError) + { + Debug.LogError($"Eve provider route rollback failed; the previous presentation will remain inactive. {rollbackError}"); + } + if (providerRestored) + { + try + { + if (swap != null) + swap.Rollback(); + else if (previousRoot != null) + previousRoot.gameObject.SetActive(previousRootWasActive); + } + catch (Exception rollbackError) + { + Debug.LogError($"Eve previous presentation rollback failed after provider restoration. {rollbackError}"); + } + } + try + { + staged?.Dispose(); + } + catch (Exception cleanupError) + { + Debug.LogWarning($"Eve rejected presentation cleanup failed. {cleanupError}"); + } + 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 { @@ -90,6 +155,255 @@ namespace GameCult.Eve.UnityScene } } + private StagedPresentation PrepareStagedPresentation() + { + var resolvedProvider = provider; + var surfaceDocuments = ResolveBehaviour( + providerSurfaceDocuments, + resolvedProvider, + nameof(providerSurfaceDocuments)); + var resolvedCommandSink = ResolveBehaviour( + commandSink, + resolvedProvider, + nameof(commandSink)); + var resolvedAssetDocuments = ResolveOptionalBehaviour( + assetManifestDocuments, + resolvedProvider); + var resolvedReceiptSource = ResolveOptionalBehaviour( + receiptSource, + resolvedProvider); + var resolvedFallbackAssetProvider = ResolveOptionalBehaviour( + fallbackAssetProvider, + resolvedProvider); + + var stagingObject = new GameObject("Eve Unity Candidate Presentation"); + stagingObject.SetActive(false); + stagingObject.transform.SetParent(transform, false); + var stagingRootObject = new GameObject("Eve Unity Candidate Scene Root"); + stagingRootObject.transform.SetParent(stagingObject.transform, false); + var stagingHost = stagingObject.AddComponent(); + stagingHost.ConnectOnEnable = false; + try + { + stagingHost.Configure( + stagingRootObject.transform, + surfaceDocuments, + resolvedCommandSink, + resolvedAssetDocuments, + resolvedReceiptSource, + resolvedFallbackAssetProvider); + var presentation = stagingHost.Connect(); + return new StagedPresentation(stagingObject, stagingRootObject.transform, stagingHost, presentation); + } + catch + { + stagingHost.Disconnect(); + DestroyObject(stagingObject); + throw; + } + } + + private PresentationSwap ActivateStagedPresentation( + StagedPresentation staged, + bool previousRootWasActive) + { + var previousHost = host; + var previousRoot = sceneRoot; + var previousPresentation = LastPresentation; + var input = GetComponent(); + var previousInputHost = input?.Host; + var previousInputCamera = input?.CameraTransform; + var rig = GetComponent(); + var previousRigHost = rig?.Host; + var previousRigCamera = rig?.CameraTransform; + var previousRigPolicy = rig?.RenderPolicySource; + + try + { + staged.GameObject.SetActive(true); + if (attachInputDriver) + ConfigureInputDriver(staged.Host); + if (attachCameraRig) + ConfigureCameraRig(staged.Host); + host = staged.Host; + sceneRoot = staged.SceneRoot; + LastPresentation = staged.Presentation; + return new PresentationSwap( + this, + staged, + _ownedPresentation, + previousHost, + previousRoot, + previousPresentation, + previousRootWasActive, + input, + previousInputHost, + previousInputCamera, + rig, + previousRigHost, + previousRigCamera, + previousRigPolicy); + } + catch + { + if (input != null) + { + input.Host = previousInputHost; + input.CameraTransform = previousInputCamera; + } + if (rig != null) + { + rig.Host = previousRigHost; + rig.CameraTransform = previousRigCamera; + rig.RenderPolicySource = previousRigPolicy; + } + host = previousHost; + sceneRoot = previousRoot; + LastPresentation = previousPresentation; + throw; + } + } + + private static void DestroyObject(UnityEngine.Object value) + { + if (Application.isPlaying) + Destroy(value); + else + DestroyImmediate(value); + } + + private sealed class StagedPresentation : IDisposable + { + public StagedPresentation( + GameObject gameObject, + Transform sceneRoot, + EveUnityPlayableWorldClientHost host, + EveUnityPlayableWorldPresentation presentation) + { + GameObject = gameObject; + SceneRoot = sceneRoot; + Host = host; + Presentation = presentation; + } + + public GameObject GameObject { get; } + public Transform SceneRoot { get; } + public EveUnityPlayableWorldClientHost Host { get; } + public EveUnityPlayableWorldPresentation Presentation { get; } + + public void Dispose() + { + try + { + Host.Disconnect(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve presentation disconnect failed during cleanup: {error}"); + } + finally + { + DestroyObject(GameObject); + } + } + } + + private sealed class PresentationSwap + { + private readonly EveUnityPlayableWorldClientBootstrap _owner; + private readonly StagedPresentation _candidate; + private readonly StagedPresentation? _previousOwnedPresentation; + private readonly EveUnityPlayableWorldClientHost? _previousHost; + private readonly Transform? _previousRoot; + private readonly EveUnityPlayableWorldPresentation? _previousPresentation; + private readonly bool _previousRootWasActive; + private readonly EveUnityPlayableWorldInputDriver? _input; + private readonly EveUnityPlayableWorldClientHost? _previousInputHost; + private readonly Transform? _previousInputCamera; + private readonly EveUnityPlayableWorldCameraRig? _rig; + private readonly EveUnityPlayableWorldClientHost? _previousRigHost; + private readonly Transform? _previousRigCamera; + private readonly IEveUnityCameraRenderPolicySource? _previousRigPolicy; + private bool _settled; + + public PresentationSwap( + EveUnityPlayableWorldClientBootstrap owner, + StagedPresentation candidate, + StagedPresentation? previousOwnedPresentation, + EveUnityPlayableWorldClientHost? previousHost, + Transform? previousRoot, + EveUnityPlayableWorldPresentation? previousPresentation, + bool previousRootWasActive, + EveUnityPlayableWorldInputDriver? input, + EveUnityPlayableWorldClientHost? previousInputHost, + Transform? previousInputCamera, + EveUnityPlayableWorldCameraRig? rig, + EveUnityPlayableWorldClientHost? previousRigHost, + Transform? previousRigCamera, + IEveUnityCameraRenderPolicySource? previousRigPolicy) + { + _owner = owner; + _candidate = candidate; + _previousOwnedPresentation = previousOwnedPresentation; + _previousHost = previousHost; + _previousRoot = previousRoot; + _previousPresentation = previousPresentation; + _previousRootWasActive = previousRootWasActive; + _input = input; + _previousInputHost = previousInputHost; + _previousInputCamera = previousInputCamera; + _rig = rig; + _previousRigHost = previousRigHost; + _previousRigCamera = previousRigCamera; + _previousRigPolicy = previousRigPolicy; + } + + public void Complete() + { + if (_settled) return; + _settled = true; + _owner._ownedPresentation = _candidate; + if (_previousOwnedPresentation != null && + !ReferenceEquals(_previousOwnedPresentation, _candidate)) + { + _previousOwnedPresentation.Dispose(); + return; + } + if (_previousHost == null || ReferenceEquals(_previousHost, _candidate.Host)) return; + try + { + _previousHost.Disconnect(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve previous presentation disconnect failed after navigation commit: {error}"); + } + } + + public void Rollback() + { + if (_settled) return; + _settled = true; + _owner.host = _previousHost; + _owner.sceneRoot = _previousRoot; + _owner.LastPresentation = _previousPresentation; + if (_input != null) + { + _input.Host = _previousInputHost; + _input.CameraTransform = _previousInputCamera; + } + if (_rig != null) + { + _rig.Host = _previousRigHost; + _rig.CameraTransform = _previousRigCamera; + _rig.RenderPolicySource = _previousRigPolicy; + } + if (_previousRoot != null) + _previousRoot.gameObject.SetActive(_previousRootWasActive); + _candidate.GameObject.SetActive(false); + } + } + public EveUnityPlayableWorldPresentation Mount() { var resolvedHost = ResolveHost(); @@ -243,4 +557,21 @@ namespace GameCult.Eve.UnityScene return null; } } + + public sealed class EveUnitySceneNavigationFailure + { + public EveUnitySceneNavigationFailure( + EveUnitySceneNavigationTarget target, + string diagnostic, + DateTimeOffset failedAtUtc) + { + Target = target; + Diagnostic = diagnostic; + FailedAtUtc = failedAtUtc; + } + + public EveUnitySceneNavigationTarget Target { get; } + public string Diagnostic { get; } + public DateTimeOffset FailedAtUtc { get; } + } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index 4fe2ebb..f8d7182 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -238,6 +238,12 @@ namespace GameCult.Eve.UnityScene public interface IEveUnityNavigableProvider { Task NavigateAsync(EveUnitySceneNavigationTarget target); + + void CommitNavigation(); + + void FinalizeNavigation(); + + void RollbackNavigation(); } public sealed class EveUnitySceneNavigationTarget @@ -247,9 +253,11 @@ namespace GameCult.Eve.UnityScene string providerId, string surfaceId, string surfaceKind, - IReadOnlyList? rendezvousEndpoints = null) + IReadOnlyList? rendezvousEndpoints = null, + string authorityRuntimeId = "") { VerseId = verseId ?? ""; + AuthorityRuntimeId = authorityRuntimeId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; @@ -257,6 +265,7 @@ namespace GameCult.Eve.UnityScene } public string VerseId { get; } + public string AuthorityRuntimeId { get; } public string ProviderId { get; } public string SurfaceId { get; } public string SurfaceKind { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index 77b9d1f..b14f549 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -172,7 +172,11 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("exposureAdaptation", "progressive"), ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f), ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f), - worldRoot.GetProp("colorGradingSpace")); + worldRoot.GetProp("colorGradingSpace"), + worldRoot.GetProp("assetProviderId"), + worldRoot.GetProp("assetVerseId"), + worldRoot.GetProp("assetAuthorityRuntimeId"), + ParseStringList(worldRoot.GetProp("assetRendezvousEndpoints"))); } private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) => @@ -270,7 +274,7 @@ namespace GameCult.Eve.UnityScene private static IReadOnlyList ParseStringList(string value) { return (value ?? "") - .Split(',') + .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Trim()) .Where(part => !string.IsNullOrWhiteSpace(part)) .Distinct(StringComparer.Ordinal) @@ -443,7 +447,11 @@ namespace GameCult.Eve.UnityScene string exposureAdaptation = "progressive", float exposureSpeedUp = 2f, float exposureSpeedDown = 1f, - string colorGradingSpace = "") + string colorGradingSpace = "", + string assetProviderId = "", + string assetVerseId = "", + string assetAuthorityRuntimeId = "", + IReadOnlyList? assetRendezvousEndpoints = null) { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -507,6 +515,10 @@ namespace GameCult.Eve.UnityScene ExposureSpeedUp = exposureSpeedUp; ExposureSpeedDown = exposureSpeedDown; ColorGradingSpace = colorGradingSpace ?? ""; + AssetProviderId = assetProviderId ?? ""; + AssetVerseId = assetVerseId ?? ""; + AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? ""; + AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty(); } public string WorldRootId { get; } @@ -525,6 +537,14 @@ namespace GameCult.Eve.UnityScene public string AssetManifest { get; } + public string AssetProviderId { get; } + + public string AssetVerseId { get; } + + public string AssetAuthorityRuntimeId { get; } + + public IReadOnlyList AssetRendezvousEndpoints { get; } + public string InputProfile { get; } public string CameraRig { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs index 34fa300..fb4481b 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs @@ -15,16 +15,20 @@ namespace GameCult.Eve.UnityScene var document = host.GetComponent(); if (document != null) return document; - var theme = Resources.Load(ThemeResource); - if (theme == null) - throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing."); + var panel = host.transform.parent?.GetComponentInParent()?.panelSettings; + if (panel == null) + { + var theme = Resources.Load(ThemeResource); + if (theme == null) + throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing."); - var panel = ScriptableObject.CreateInstance(); - panel.name = "Eve playable-world HUD panel"; - panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; - panel.referenceResolution = new Vector2Int(1920, 1080); - panel.match = 0.5f; - panel.themeStyleSheet = theme; + panel = ScriptableObject.CreateInstance(); + panel.name = "Eve playable-world HUD panel"; + panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; + panel.referenceResolution = new Vector2Int(1920, 1080); + panel.match = 0.5f; + panel.themeStyleSheet = theme; + } document = host.AddComponent(); document.panelSettings = panel; 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..376bc8a 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -2,11 +2,15 @@ using System; using System.Reflection; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; 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 +18,324 @@ 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 CommandOutboxCompletesOnlyAfterCanonicalReceipt() + { + 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, + retryDelay: TimeSpan.FromMilliseconds(250)); + + 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.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] + 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"], + retryDelay: TimeSpan.FromMilliseconds(250)); + + 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.StateOf("move-1000") == + EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt); + Assert.That(outbox.Acknowledge("move-1000"), Is.True); + await WaitUntilAsync(() => outbox.PendingCount == 0); + lock (sent) + { + CollectionAssert.AreEqual(new[] { "move-0", "move-1000" }, sent); + } + } + + [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 PendingReceiptIsObservedWithoutRetiringTheCommand() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("launch-1", "launch"); + var pendingField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance); + var publish = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "PublishReceipt", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(pendingField, Is.Not.Null); + Assert.That(publish, Is.Not.Null); + var pending = (IDictionary)pendingField!.GetValue(transport)!; + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + + publish!.Invoke(transport, new object[] { Receipt(request, "pending", "receipt-pending") }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending" })); + + publish.Invoke(transport, new object[] + { + Receipt(request, "accepted", "receipt-accepted", sourceVersion: 1000) + }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.False); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending", "accepted" })); + } + + [Test] + public void TerminalReceiptWaitsForItsPresentationGenerationAndFreezesTheOldSurface() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("select-verse-1", "selectVerse"); + var type = typeof(EveUnityCultMeshLiveProviderTransport); + var pending = (IDictionary)type.GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(transport)!; + var publish = type.GetMethod("PublishReceipt", BindingFlags.NonPublic | BindingFlags.Instance)!; + var complete = type.GetMethod( + "CompletePresentationTransition", + BindingFlags.NonPublic | BindingFlags.Instance)!; + type.GetField("_bootstrapped", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(transport, true); + complete.Invoke(transport, new object[] { 1L }); + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + using var outbox = new EveUnityCultMeshCommandOutbox( + (_, _) => Task.CompletedTask, + _ => null, + retryDelay: TimeSpan.FromSeconds(30), + maximumRetryDelay: TimeSpan.FromSeconds(30)); + type.GetField("_commandOutbox", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(transport, outbox); + outbox.Enqueue(request); + + publish.Invoke(transport, new object[] + { + Receipt( + request, + "accepted", + "receipt-accepted", + sourceVersion: 1000, + presentationSurfaceVersion: 7) + }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(outbox.PendingCount, Is.Zero); + Assert.That(observed, Is.Empty); + type.GetProperty("CurrentSurfaceDocument")!.SetValue(transport, SceneSurface(100)); + var blocked = Assert.Throws(() => + transport.SubmitCommand(Request("stale-launch", "launch"))); + StringAssert.Contains("read-only", blocked!.Message); + + complete.Invoke(transport, new object[] { 7L }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.False); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "accepted" })); + } + + [Test] + public void ReceiptForAnotherInvocationCannotRetireTheCommand() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("launch-1", "launch"); + var pendingField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance); + var publish = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "PublishReceipt", + BindingFlags.NonPublic | BindingFlags.Instance); + var pending = (IDictionary)pendingField!.GetValue(transport)!; + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + var collision = new EveCommandReceiptDocument( + "receipt-collision", + request.CommandId, + request.Command, + "accepted", + "AetheriaEve", + "test.runtime", + request.ProviderId, + request.SurfaceId, + "", + DateTimeOffset.UtcNow.ToString("O"), + 1, + invocationHash: EveCommandInvocationHash.Compute(Request(request.CommandId, "another-payload"))); + + publish!.Invoke(transport, new object[] { collision }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed, Is.Empty); + } + + [TestCase("provider", "other.provider")] + [TestCase("surface", "other.surface")] + [TestCase("authority", "other.runtime")] + [TestCase("state", "mysterious")] + public void UntrustedReceiptCannotRetireOrPublishTheCommand(string field, string value) + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("launch-untrusted", "launch"); + var type = typeof(EveUnityCultMeshLiveProviderTransport); + var pending = (IDictionary)type.GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(transport)!; + var publish = type.GetMethod("PublishReceipt", BindingFlags.NonPublic | BindingFlags.Instance)!; + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + + var providerId = field == "provider" ? value : request.ProviderId; + var surfaceId = field == "surface" ? value : request.SurfaceId; + var authority = field == "authority" ? value : "test.runtime"; + var state = field == "state" ? value : "accepted"; + var receipt = new EveCommandReceiptDocument( + "receipt-untrusted-" + field, + request.CommandId, + request.Command, + state, + "AetheriaEve", + authority, + providerId, + surfaceId, + "", + DateTimeOffset.UtcNow.ToString("O"), + 1, + invocationHash: EveCommandInvocationHash.Compute(request)); + + publish.Invoke(transport, new object[] { receipt }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed, Is.Empty); + } + [Test] public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute() { @@ -26,11 +348,177 @@ 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); } + [Test] + public void NavigationAuthorityCannotBeReplacedByAlphabeticallyEarlierPeer() + { + var target = new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3075" }, + "z-owner"); + var eligible = EveUnityCultMeshProviderDiscovery.EligibleAuthorityRuntimeIds( + new[] { "a-decoy", "z-owner" }, + target.AuthorityRuntimeId); + + Assert.That(target.AuthorityRuntimeId, Is.EqualTo("z-owner")); + CollectionAssert.AreEqual(new[] { "z-owner" }, eligible); + } + + 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 EveCommandReceiptDocument Receipt( + EveSurfaceCommandRequest request, + string state, + string receiptId, + long sourceVersion = 0, + long presentationSurfaceVersion = 0) => new EveCommandReceiptDocument( + receiptId, + request.CommandId, + request.Command, + state, + "AetheriaEve", + "test.runtime", + request.ProviderId, + request.SurfaceId, + "", + DateTimeOffset.UtcNow.ToString("O"), + sourceVersion, + invocationHash: EveCommandInvocationHash.Compute(request), + presentationSurfaceVersion: presentationSurfaceVersion); + + [Test] + public void FailedNewestCandidateCanRetryWithoutAllowingAnOlderSurface() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var begin = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "TryBeginBaseSurfaceCandidate", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var retire = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "RetireFailedBaseSurfaceCandidate", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var first = new object[] { 10L, -1L }; + var older = new object[] { 7L, -1L }; + var duplicate = new object[] { 10L, -1L }; + + Assert.That(begin.Invoke(transport, first), Is.True); + Assert.That(begin.Invoke(transport, older), Is.False); + Assert.That(begin.Invoke(transport, duplicate), Is.False); + retire.Invoke(transport, new[] { first[0], first[1] }); + var retry = new object[] { 10L, -1L }; + Assert.That(begin.Invoke(transport, retry), Is.True); + Assert.That((long)retry[1], Is.GreaterThan((long)first[1])); + Assert.That(begin.Invoke(transport, new object[] { 9L, -1L }), Is.False); + } + + [Test] + public async Task FailedAssetPreparationRunsBoundedRetryPolicyAndRetiresCandidate() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var type = typeof(EveUnityCultMeshLiveProviderTransport); + var begin = type.GetMethod( + "TryBeginBaseSurfaceCandidate", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var refresh = type.GetMethod( + "RefreshAssetsForSurfaceAsync", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var attempts = type.GetField( + "_lastAssetPresentationAttemptCount", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var first = new object[] { 10L, -1L }; + Assert.That(begin.Invoke(transport, first), Is.True); + var task = (Task)refresh.Invoke(transport, new object[] + { + SceneSurface(10).SurfaceDocument, + CreateAssetSource("test.verse", "test.runtime", "test.provider", "catalog", 41), + (long)first[1], + CancellationToken.None + })!; + + await task; + + Assert.That(attempts.GetValue(transport), Is.EqualTo(3)); + Assert.That(begin.Invoke(transport, new object[] { 10L, -1L }), Is.True); + } + + private static EveUnitySceneProviderSurfaceDocument SceneSurface(long version) + { + var surface = new EveSurfaceDocument( + "test.provider", + "", + "", + version, + "", + new EveSurfaceTree( + "test.surface", + new EveSurfaceComponent( + "root", + "surface", + new Dictionary(), + Array.Empty()), + Array.Empty()), + Array.Empty()); + return new EveUnitySceneProviderSurfaceDocument( + surface, + new EveUnitySceneProviderSurfaceAdvertisement( + "test.surface", + "interactive-world", + new EveUnitySceneWorldInteraction("", "", EveCommandReceiptDocument.SchemaId, "")), + "test:surface", + version); + } + + 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() { @@ -158,29 +646,16 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void PublishingCatalogRegistersBundlesWithoutMaterializingThem() + public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem() { var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( "CurrentBundlePlatform", BindingFlags.Static | BindingFlags.NonPublic); - var publishMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( - "PublishAssetCatalog", - BindingFlags.Instance | BindingFlags.NonPublic); - var bundlesField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( - "_assetBundles", - BindingFlags.Instance | BindingFlags.NonPublic); - var selectionsField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( - "_assetSelections", - BindingFlags.Instance | BindingFlags.NonPublic); + var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "ConfigureAssetCatalog", + BindingFlags.Static | BindingFlags.NonPublic); Assert.That(platformMethod, Is.Not.Null); - Assert.That(publishMethod, Is.Not.Null); - - using var transport = new EveUnityCultMeshLiveProviderTransport( - Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"), - "cultnet+tcp://127.0.0.1:1", - "provider", - "provider", - "surface"); + Assert.That(configureMethod, Is.Not.Null); var variant = new EveAssetVariant( "unity-scene", (string)platformMethod!.Invoke(null, Array.Empty())!, @@ -195,11 +670,229 @@ namespace GameCult.Eve.UnityScene.Tests 1, DateTimeOffset.UtcNow.ToString("O"), new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) }); + var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1); - publishMethod!.Invoke(transport, new object[] { catalog }); + configureMethod!.Invoke(null, new[] { candidate, catalog }); - Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero); - Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).Count, Is.EqualTo(1)); + Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero); + Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1)); + ((IDisposable)candidate).Dispose(); } + + [Test] + public void FailedAssetCandidatePreservesCommittedGeneration() + { + var transportType = typeof(EveUnityCultMeshLiveProviderTransport); + var generationField = transportType.GetField( + "_assetGeneration", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var configureMethod = transportType.GetMethod( + "ConfigureAssetCatalog", + BindingFlags.Static | BindingFlags.NonPublic)!; + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a"); + var initial = (IDisposable)generationField.GetValue(transport)!; + var committed = CreateAssetGeneration( + "verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 1); + var prefab = new GameObject("committed-a"); + GenerationDictionary(committed, "Prefabs")["prefab.a"] = prefab; + initial.Dispose(); + generationField.SetValue(transport, committed); + + var invalidVariant = new EveAssetVariant( + "browser", + "web", + "url", + "https://example.invalid/a", + "sha256:00", + 1, + "prefab.a"); + var invalidCatalog = new EveAssetCatalogDocument( + "provider-b", + "catalog-b", + 2, + DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) }); + var rejected = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1); + var error = Assert.Throws(() => + configureMethod.Invoke(null, new[] { rejected, invalidCatalog })); + Assert.That(error!.InnerException, Is.TypeOf()); + ((IDisposable)rejected).Dispose(); + Assert.That(generationField.GetValue(transport), Is.SameAs(committed)); + Assert.That(GenerationProperty(committed, "SourceIdentity"), Is.EqualTo("source-a")); + Assert.That(GenerationDictionary(committed, "Prefabs")["prefab.a"], Is.SameAs(prefab)); + UnityEngine.Object.DestroyImmediate(prefab); + } + + [Test] + public void CandidateCatalogObservationSurvivesPrecommitWindow() + { + var candidate = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", 1); + var candidateType = candidate.GetType(); + var observe = candidateType.GetMethod("ObserveCatalog")!; + var activate = candidateType.GetMethod("ActivateCatalogObservation")!; + var variant = new EveAssetVariant( + "unity-scene", "StandaloneWindows64", "url", "https://example.invalid/b", "sha256:01", 1, "prefab.b"); + var catalogB = new EveAssetCatalogDocument( + "provider-b", "catalog-b", 2, DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { variant }) }); + + Assert.That(observe.Invoke(candidate, new object[] { catalogB }), Is.Null); + Assert.That(activate.Invoke(candidate, Array.Empty()), Is.SameAs(catalogB)); + ((IDisposable)candidate).Dispose(); + } + + [Test] + public void PinnedSurfaceCatalogDoesNotAutoAdvanceWithoutANewerBaseSurface() + { + var transportType = typeof(EveUnityCultMeshLiveProviderTransport); + var generationField = transportType.GetField( + "_assetGeneration", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var pendingField = transportType.GetField( + "_pendingAssetCatalog", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var queue = transportType.GetMethod( + "QueueAssetCatalogUpdate", + BindingFlags.Instance | BindingFlags.NonPublic)!; + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-pinned-catalog-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a"); + var initial = (IDisposable)generationField.GetValue(transport)!; + var committed = CreateAssetGeneration( + "verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 41, 41); + initial.Dispose(); + generationField.SetValue(transport, committed); + var catalog42 = new EveAssetCatalogDocument( + "provider-a", + "catalog-a", + 42, + DateTimeOffset.UtcNow.ToString("O"), + Array.Empty()); + + queue.Invoke(transport, new object[] { "source-a", catalog42 }); + + Assert.That(GenerationProperty(generationField.GetValue(transport)!, "CatalogVersion"), Is.EqualTo(41)); + var committedSource = GenerationProperty(generationField.GetValue(transport)!, "Source"); + Assert.That(GenerationProperty(committedSource, "RequiredCatalogVersion"), Is.EqualTo(41)); + Assert.That(pendingField.GetValue(transport), Is.Null); + } + + [Test] + public void PinnedSurfaceRejectsWrongImmutableCatalogRecordImmediately() + { + var require = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "RequireCatalogVersion", + BindingFlags.Static | BindingFlags.NonPublic)!; + var catalog42 = new EveAssetCatalogDocument( + "provider-a", + "eve:assets:aetheria.daemon:version:42", + 42, + DateTimeOffset.UtcNow.ToString("O"), + Array.Empty()); + + var error = Assert.Throws(() => require.Invoke( + null, + new object[] { catalog42, 41L, "eve:assets:aetheria.daemon:version:41" })); + + Assert.That(error!.InnerException, Is.TypeOf()); + Assert.That(error.InnerException!.Message, Does.Contain("requires immutable version 41")); + } + + [Test] + public void CrossTargetAssetsUseConfiguredRemoteTrust() + { + var localTrust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment); + var remoteTrust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.AuthenticatedRemote); + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-asset-trust-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a", + authorityTrust: localTrust, + crossTargetAuthorityTrust: remoteTrust); + var trustMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "AssetAuthorityTrust", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + var localSource = CreateAssetSource("verse-a", "authority-a", "provider-a", "catalog-a"); + var remoteSource = CreateAssetSource("verse-b", "authority-b", "provider-b", "catalog-b"); + + Assert.That(trustMethod.Invoke(transport, new[] { localSource }), Is.SameAs(localTrust)); + Assert.That(trustMethod.Invoke(transport, new[] { remoteSource }), Is.SameAs(remoteTrust)); + } + + private static object CreateAssetSource( + string verseId, + string authorityRuntimeId, + string providerId, + string manifestRecordRef, + long requiredCatalogVersion = 0) + { + var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType( + "AssetSource", + BindingFlags.NonPublic)!; + return Activator.CreateInstance( + type, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new object[] + { + new CultMeshSessionTarget(verseId, authorityRuntimeId), + providerId, + manifestRecordRef, + Array.Empty(), + requiredCatalogVersion + }, + null)!; + } + + private static object CreateAssetGeneration( + string verseId, + string authorityRuntimeId, + string providerId, + string manifestRecordRef, + string sourceIdentity, + long catalogVersion, + long requiredCatalogVersion = 0) + { + var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType( + "AssetGeneration", + BindingFlags.NonPublic)!; + return Activator.CreateInstance( + type, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] + { + CreateAssetSource(verseId, authorityRuntimeId, providerId, manifestRecordRef, requiredCatalogVersion), + sourceIdentity, + catalogVersion, + null + }, + null)!; + } + + private static System.Collections.IList GenerationCollection(object generation, string property) => + (System.Collections.IList)generation.GetType().GetProperty(property)!.GetValue(generation)!; + + private static System.Collections.IDictionary GenerationDictionary(object generation, string property) => + (System.Collections.IDictionary)generation.GetType().GetProperty(property)!.GetValue(generation)!; + + private static T GenerationProperty(object generation, string property) => + (T)generation.GetType().GetProperty(property)!.GetValue(generation)!; } } 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..7f83143 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Text.RegularExpressions; using System.Collections; using System.Threading.Tasks; using GameCult.Caching; @@ -32,6 +33,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "aetheria", "aetheria", + "aetheria", "aetheria.pilot"); }); } @@ -67,6 +69,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 +105,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 +135,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 +166,7 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "provider", "provider", + "provider", "provider.pilot"); var view = EntityLeaseDocument(); view.Sequence = 0; @@ -576,6 +582,10 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(projection.PlayableWorld.EntityBodyId, Is.EqualTo("eve:entity-soa:aetheria.daemon:pilot")); Assert.That(projection.PlayableWorld.ZoneRenderPointerId, Is.EqualTo("cultmesh://aetheria/world/zone-render")); Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(projection.PlayableWorld.AssetProviderId, Is.EqualTo("aetheria.assets")); + Assert.That(projection.PlayableWorld.AssetVerseId, Is.EqualTo("aetheria.mods")); + Assert.That(projection.PlayableWorld.AssetAuthorityRuntimeId, Is.EqualTo("mod-authority")); + Assert.That(projection.PlayableWorld.AssetRendezvousEndpoints, Is.EqualTo(new[] { "wss://odin-a/cultmesh", "wss://odin-b/cultmesh" })); Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person")); Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1")); Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty); @@ -1165,6 +1175,9 @@ namespace GameCult.Eve.UnityScene.Tests var bootstrap = hostObject.AddComponent(); bootstrap.ConfigureProvider(provider); bootstrap.Mount(); + var originalHost = bootstrap.Host; + var originalRoot = bootstrap.SceneRoot!; + provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf; provider.PublishReceipt(new EveUnitySceneCommandReceipt( "receipt:launch", @@ -1186,7 +1199,153 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(provider.LastNavigation, Is.Not.Null); Assert.That(provider.LastNavigation!.VerseId, Is.EqualTo("gamecult.aetheria")); Assert.That(provider.LastNavigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.example:3076" })); - Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(2)); + Assert.That(bootstrap.Host, Is.Not.SameAs(originalHost)); + Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1)); + Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(1)); + Assert.That(provider.RollbackNavigationCount, Is.Zero); + Assert.That(provider.PreviousPresentationActiveDuringCommit, Is.False); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-again", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.EqualTo(2)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(2)); + Assert.That(provider.ActiveConnectionLeases, Is.EqualTo(1), + "disposing superseded presentations must leave the committed Verse connected"); + Assert.That(hostObject.transform.Cast() + .Count(child => child.name == "Eve Unity Candidate Presentation"), Is.EqualTo(1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapKeepsMountedPresentationAfterNavigationMountFailure() + { + var hostObject = new GameObject("generic-eve-navigation-rollback-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalHost = bootstrap.Host; + var originalRuntime = originalHost!.Runtime; + var originalRoot = bootstrap.SceneRoot; + var originalPresentation = bootstrap.LastPresentation; + provider.FailNextNavigationMount = true; + LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted")); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-failure", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "broken-provider", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3999" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.Zero); + Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1)); + Assert.That(bootstrap.LastNavigationFailure, Is.Not.Null); + Assert.That(bootstrap.Host, Is.SameAs(originalHost)); + Assert.That(bootstrap.Host!.Runtime, Is.SameAs(originalRuntime)); + Assert.That(bootstrap.SceneRoot, Is.SameAs(originalRoot)); + Assert.That(bootstrap.LastPresentation, Is.SameAs(originalPresentation)); + Assert.That(bootstrap.Host!.Runtime, Is.Not.Null); + Assert.That(bootstrap.Host.ActiveWorld, Is.Not.Null); + Assert.That(bootstrap.Host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(bootstrap.Host.ConnectionEpoch, Is.EqualTo(1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapRestoresProviderRouteBeforeReactivatingPreviousView() + { + var hostObject = new GameObject("generic-eve-navigation-order-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalRoot = bootstrap.SceneRoot!; + provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf; + provider.FailNextNavigationFinalize = true; + LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted")); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-finalize-failure", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "candidate-provider", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3999" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); + Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1)); + Assert.That(provider.ActiveRoute, Is.EqualTo("original")); + Assert.That(provider.PreviousPresentationActiveDuringRollback, Is.False); + Assert.That(originalRoot.gameObject.activeSelf, Is.True); + Assert.That(bootstrap.Host!.Runtime, Is.Not.Null); } finally { @@ -2575,6 +2734,10 @@ namespace GameCult.Eve.UnityScene.Tests ["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render", ["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1", ["assetManifest"] = "cultmesh://aetheria/assets/manifest", + ["assetProviderId"] = "aetheria.assets", + ["assetVerseId"] = "aetheria.mods", + ["assetAuthorityRuntimeId"] = "mod-authority", + ["assetRendezvousEndpoints"] = "wss://odin-a/cultmesh;wss://odin-b/cultmesh", ["inputProfile"] = "arpg-third-person", ["cameraRig"] = cameraRig, ["cameraLookAt"] = cameraLookAt, @@ -3280,6 +3443,7 @@ namespace GameCult.Eve.UnityScene.Tests IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, IEveUnityInputCapabilitySource, + IEveUnitySceneProviderSurfaceDocumentConnection, IEveUnityNavigableProvider { public List Submitted { get; } = new List(); @@ -3288,6 +3452,28 @@ namespace GameCult.Eve.UnityScene.Tests public EveUnitySceneNavigationTarget? LastNavigation { get; private set; } + public int CommitNavigationCount { get; private set; } + + public int FinalizeNavigationCount { get; private set; } + + public int RollbackNavigationCount { get; private set; } + + public bool FailNextNavigationMount { get; set; } + + public bool FailNextNavigationFinalize { get; set; } + + public int ActiveConnectionLeases { get; private set; } + + public string ActiveRoute { get; private set; } = "original"; + + public Func? PreviousPresentationActive { get; set; } + + public bool? PreviousPresentationActiveDuringCommit { get; private set; } + + public bool? PreviousPresentationActiveDuringRollback { get; private set; } + + private bool _failSurfaceRead; + public string SinkKind => "fake-provider-command-sink"; public string ManifestRef => CurrentDocument.ManifestRef; @@ -3309,7 +3495,9 @@ namespace GameCult.Eve.UnityScene.Tests new EveInputCapabilityDocument(); EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => - CurrentSurfaceDocument; + _failSurfaceRead + ? throw new InvalidOperationException("Deliberate candidate surface read failure.") + : CurrentSurfaceDocument; public event Action? DocumentAvailable; @@ -3353,8 +3541,47 @@ namespace GameCult.Eve.UnityScene.Tests public Task NavigateAsync(EveUnitySceneNavigationTarget target) { LastNavigation = target; + _failSurfaceRead = FailNextNavigationMount; + FailNextNavigationMount = false; return Task.CompletedTask; } + + public void Connect() + { + ActiveConnectionLeases++; + } + + public void Disconnect() + { + if (ActiveConnectionLeases > 0) + ActiveConnectionLeases--; + } + + public void CommitNavigation() + { + PreviousPresentationActiveDuringCommit = PreviousPresentationActive?.Invoke(); + CommitNavigationCount++; + ActiveRoute = "candidate"; + _failSurfaceRead = false; + } + + public void FinalizeNavigation() + { + FinalizeNavigationCount++; + if (FailNextNavigationFinalize) + { + FailNextNavigationFinalize = false; + throw new InvalidOperationException("Deliberate provider finalization failure."); + } + } + + public void RollbackNavigation() + { + RollbackNavigationCount++; + ActiveRoute = "original"; + _failSurfaceRead = false; + PreviousPresentationActiveDuringRollback = PreviousPresentationActive?.Invoke(); + } } } } diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 1538e10..f5dcbe0 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.134", "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.56", "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.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", "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 f6e4fcd..f5397a2 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) { @@ -45,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; @@ -130,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": @@ -273,6 +277,7 @@ namespace GameCult.Eve.UnityUIToolkit return; if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink)) _inventoryDragSource = null; + ClearInventoryPreview(); }); return grid; } @@ -291,6 +296,7 @@ namespace GameCult.Eve.UnityUIToolkit _inventoryDragSource = component; _inventoryDragStart = evt.position; _inventoryPointerMoved = false; + ClearInventoryPreview(); item.CapturePointer(evt.pointerId); evt.StopPropagation(); }); @@ -300,7 +306,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 +329,7 @@ namespace GameCult.Eve.UnityUIToolkit TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink); _inventoryDragSource = null; _suppressInventoryClick = true; + ClearInventoryPreview(); } evt.StopPropagation(); }); @@ -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; @@ -479,18 +538,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 @@ -600,6 +667,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)) @@ -676,6 +749,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 93b83c5..20b98bb 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; @@ -51,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