diff --git a/.gitignore b/.gitignore index 975deb4..2c9c34e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,3 @@ 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 8d8cef4..0bfa008 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -310,8 +310,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); var quicProof = ReceiveAdvertisedRealtimeFrameAsync( rendezvousEndpoint, - provider.Selection.VerseId, - provider.Selection.AuthorityRuntimeId); + provider.Selection.VerseId); var quicDeadline = Time.realtimeSinceStartup + 15f; while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) yield return new WaitForSecondsRealtime(0.05f); @@ -1201,8 +1200,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests private static async Task ReceiveAdvertisedRealtimeFrameAsync( string rendezvousEndpoint, - string verseId, - string authorityRuntimeId) + string verseId) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var client = new CultMeshClient(new CultMeshClientOptions @@ -1213,9 +1211,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests new CultMeshNativeQuicRealtimeTransportConnector() } }); - using var session = await client.ConnectRealtimeAsync( - new CultMeshSessionTarget(verseId, authorityRuntimeId), - timeout.Token); + using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token); return await session.ReceiveAsync(timeout.Token); } diff --git a/ReleaseConsumerProject/Packages/manifest.json b/ReleaseConsumerProject/Packages/manifest.json index b82d19d..07406db 100644 --- a/ReleaseConsumerProject/Packages/manifest.json +++ b/ReleaseConsumerProject/Packages/manifest.json @@ -6,11 +6,11 @@ "com.unity.modules.physics": "1.0.0", "com.unity.modules.uielements": "1.0.0", "com.unity.render-pipelines.universal": "17.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45", "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", - "org.gamecult.eve.surface": "https://github.com/GameCult/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" + "org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0", + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9", + "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9" }, "testables": [ "org.gamecult.eve.unity-scene", diff --git a/ReleaseConsumerProject/Packages/packages-lock.json b/ReleaseConsumerProject/Packages/packages-lock.json index 8ad82a2..4510f6a 100644 --- a/ReleaseConsumerProject/Packages/packages-lock.json +++ b/ReleaseConsumerProject/Packages/packages-lock.json @@ -119,11 +119,11 @@ } }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45", "depth": 0, "source": "git", "dependencies": {}, - "hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e" + "hash": "03840d0430bc727f1322861a1ab46b396eaca860" }, "org.gamecult.eve.plugin-fields": { "version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", @@ -135,37 +135,37 @@ "hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0" }, "org.gamecult.eve.surface": { - "version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825", + "version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.56" + "org.gamecult.cultlib": "1.0.45" }, - "hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825" + "hash": "fe0dddf28267decbb416325a3d0b2c62432825c2" }, "org.gamecult.eve.unity-scene": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.56", + "org.gamecult.cultlib": "1.0.45", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.6", - "org.gamecult.eve.unity-uitoolkit": "0.1.9", + "org.gamecult.eve.surface": "0.3.0", + "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" }, - "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" + "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" }, "org.gamecult.eve.unity-uitoolkit": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.eve.surface": "0.3.6" + "org.gamecult.eve.surface": "0.3.0" }, - "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" + "hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9" }, "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 8d8cef4..0bfa008 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -310,8 +310,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); var quicProof = ReceiveAdvertisedRealtimeFrameAsync( rendezvousEndpoint, - provider.Selection.VerseId, - provider.Selection.AuthorityRuntimeId); + provider.Selection.VerseId); var quicDeadline = Time.realtimeSinceStartup + 15f; while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) yield return new WaitForSecondsRealtime(0.05f); @@ -1201,8 +1200,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests private static async Task ReceiveAdvertisedRealtimeFrameAsync( string rendezvousEndpoint, - string verseId, - string authorityRuntimeId) + string verseId) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var client = new CultMeshClient(new CultMeshClientOptions @@ -1213,9 +1211,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests new CultMeshNativeQuicRealtimeTransportConnector() } }); - using var session = await client.ConnectRealtimeAsync( - new CultMeshSessionTarget(verseId, authorityRuntimeId), - timeout.Token); + using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token); return await session.ReceiveAsync(timeout.Token); } diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index d631673..bbda013 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -137,7 +137,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.56" + "org.gamecult.cultlib": "1.0.45" } }, "org.gamecult.eve.unity-scene": { @@ -145,10 +145,10 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.56", + "org.gamecult.cultlib": "1.0.45", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.6", - "org.gamecult.eve.unity-uitoolkit": "0.1.9", + "org.gamecult.eve.surface": "0.3.0", + "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" @@ -159,7 +159,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.eve.surface": "0.3.6" + "org.gamecult.eve.surface": "0.3.0" } }, "com.unity.modules.assetbundle": { diff --git a/docs/aetheria-released-package-witness.md b/docs/aetheria-released-package-witness.md index 6446cec..b38f763 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.134`, commit - `d1663b36d12b60ff5c7249135eff48edf6227ca9`, and - `org.gamecult.eve.surface` `0.3.6`, commit - `96839ad34c8d464ef622d8bbdd5d277e1ca9d825`; +- `org.gamecult.eve.unity-scene` `0.3.100`, commit + `3632bfb3354065b78a90b921d7025c4c3fa92be4`, and + `org.gamecult.eve.surface` `0.2.4`, commit + `e08fa08335f99e9edddeb706912eecfad07cb281`; - `org.gamecult.eve.plugin-fields` `0.2.3`, commit `c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`; -- `org.gamecult.eve.unity-uitoolkit` `0.1.9`, commit - `d1663b36d12b60ff5c7249135eff48edf6227ca9`; -- `org.gamecult.cultlib` `1.0.56`, commit - `334e60f1928b4212a29dd8b0d19b2c099fe6365e`. +- `org.gamecult.eve.unity-uitoolkit` `0.1.1`, commit + `4d0cbe0185bdc4fc65eb63503a7c5cb578539669`; +- `org.gamecult.cultlib` `1.0.43`, commit + `f67f5122ed1bd11da016e7b820ed60145ccd0299`. 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 ` + -CultLibRoot E:\Projects\CultLib-codex-cultmesh-reliability ` -CacheState cold ` - -AssetCacheDirectory artifacts\admitted-release-cold-cache ` - -OutputDirectory artifacts\admitted-release-cold ` + -AssetCacheDirectory artifacts\cultlib-1043-scene-03100-cold-cache ` + -OutputDirectory artifacts\cultlib-1043-scene-03100-cold ` -SkipAssetBundleBuild ``` @@ -31,13 +31,9 @@ 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. This -admission pass proves the package graph with the isolated EditMode consumer; it -does not claim a new rendered end-to-end witness. +Aetheria-specific gameplay code participates in the Unity runtime. -## Historical witness ledger - -The retained cold proof is +The current 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 @@ -49,7 +45,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 retained warm full-session proof is +The current 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 @@ -67,6 +63,8 @@ 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 3be5f6c..4b25203 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -76,13 +76,6 @@ 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 @@ -105,10 +98,8 @@ modes into one camera opinion or infer a product camera from entity kind. ## Shared Paths -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. +Initial connect asks one rendezvous for Verses, selects a provider surface by +semantic kind, then leases it through the selected stable provider identity. Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain inside the same identity-owned session. Movement, focus, target, and action input all become `gamecult.eve.command_invocation.v1` through the diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 52d0a60..6edac13 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -16,47 +16,20 @@ 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 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. +selection filters; they are not product knowledge required by the client. +The selected provider ID remains the stable CultMesh endpoint identity while +the Verse ID names the selected rule/progression world. Physical routes are +resolved and re-resolved inside `CultMeshClient`; they are never +published as provider selection state or cached as Unity authority. The optional +local path is only an ephemeral content/body cache, not a replicated gameplay +database. Await `PrepareAsync()` before mounting the bootstrap or reading provider ports. -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. +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. The package never imports provider product assemblies. @@ -176,13 +149,3 @@ 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 deleted file mode 100644 index f8c89fa..0000000 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs +++ /dev/null @@ -1,296 +0,0 @@ -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 deleted file mode 100644 index a6c7e7a..0000000 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 5e163c8..0000000 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index a701492..0000000 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -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 5a403f4..06a3175 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -24,24 +24,29 @@ namespace GameCult.Eve.UnityScene IEveUnityGameObjectAssetProvider, IDisposable { - private const int AssetPresentationAttemptLimit = 3; - private readonly string _cachePath; private readonly string _rendezvousEndpoint; - private readonly CultMeshSessionTarget _target; + private readonly string _endpointId; private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; - 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 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 ConcurrentQueue _liveDocuments = new ConcurrentQueue(); private readonly object _realtimeFrameGate = new object(); private readonly Dictionary _latestRealtimeFrames = @@ -52,12 +57,11 @@ 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 AssetGeneration _assetGeneration; + private CultMeshContentTransferService? _contentTransfer; + private CultMeshVerifiedBodyMappingBroker? _assetBodyMappings; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; private EveSurfaceDocument? _baseSurface; @@ -67,19 +71,8 @@ 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; @@ -90,14 +83,11 @@ namespace GameCult.Eve.UnityScene public EveUnityCultMeshLiveProviderTransport( string cachePath, string rendezvousEndpoint, - string verseId, - string authorityRuntimeId, + string endpointId, string providerId, string surfaceId, string runtimeId = "eve-unity", - CultMeshBodyPublicationResolver? bodyResolver = null, - CultMeshAuthorityTrustPolicy? authorityTrust = null, - CultMeshAuthorityTrustPolicy? crossTargetAuthorityTrust = null) + CultMeshBodyPublicationResolver? bodyResolver = null) { _cachePath = string.IsNullOrWhiteSpace(cachePath) ? throw new ArgumentException("Cache path must be non-empty.", nameof(cachePath)) @@ -105,23 +95,17 @@ namespace GameCult.Eve.UnityScene _rendezvousEndpoint = string.IsNullOrWhiteSpace(rendezvousEndpoint) ? throw new ArgumentException("CultMesh rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)) : rendezvousEndpoint.Trim(); - _target = new CultMeshSessionTarget(verseId, authorityRuntimeId); + _endpointId = string.IsNullOrWhiteSpace(endpointId) + ? throw new ArgumentException("CultMesh stable endpoint identity must be non-empty.", nameof(endpointId)) + : endpointId.Trim(); _providerId = string.IsNullOrWhiteSpace(providerId) ? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)) : providerId.Trim(); - _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(); @@ -139,7 +123,7 @@ namespace GameCult.Eve.UnityScene public EveInputCapabilityDocument CurrentInputCapability { get; private set; } - public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind => _assetGeneration.BodyTransportKind; + public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind { get; private set; } public event Action? SurfaceDocumentAvailable; @@ -164,7 +148,8 @@ namespace GameCult.Eve.UnityScene public void Connect() { - EnsurePrepared(); + EnsureOpen(); + BootstrapIfRequired(); } public void Disconnect() @@ -173,48 +158,42 @@ namespace GameCult.Eve.UnityScene public void Refresh() { - EnsurePrepared(); - PumpLiveEvents(); + EnsureOpen(); + if (_bootstrapped) + { + PumpLiveEvents(); + return; + } + + BootstrapIfRequired(); } - public async Task PrepareAsync(CancellationToken cancellationToken = default) + private void BootstrapIfRequired() { if (_bootstrapped) return; try { - EnsureOpen(); var elapsed = Stopwatch.StartNew(); - await ResolveAdvertisementAsync(forceRefresh: true, cancellationToken); + ResolveAdvertisement(forceRefresh: true); TraceStartup("advertisement", elapsed); - await RefreshSurfaceAsync(cancellationToken); + RefreshSurface(); TraceStartup("surface", elapsed); - await RefreshAssetCatalogAsync(cancellationToken); + RefreshAssetCatalog(); TraceStartup("asset-catalog-and-bundles", elapsed); - await EnsureLiveDocumentsAsync(cancellationToken); + EnsureLiveDocuments(); 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 target '{_target}' failed.", + $"CultMesh provider '{_providerId}' bootstrap through stable endpoint '{_endpointId}' 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)) @@ -256,7 +235,7 @@ namespace GameCult.Eve.UnityScene else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; else if (document is EveAssetCatalogDocument assetCatalog) - QueueAssetCatalogUpdate(CurrentAssetSourceIdentity, assetCatalog); + PublishAssetCatalog(assetCatalog); else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); else if (document is Exception error) @@ -362,8 +341,8 @@ namespace GameCult.Eve.UnityScene public void SubmitCommand(EveSurfaceCommandRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); - EnsurePrepared(); - EnsurePresentationWritable(); + EnsureOpen(); + ResolveAdvertisement(); var interaction = RequireWorldInteraction(); if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef)) throw new InvalidOperationException("The provider advertisement does not publish a command record reference."); @@ -377,79 +356,52 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(commandId)) throw new InvalidOperationException("Eve command invocations require an idempotency key."); - lock (_pendingCommands) + _pendingCommandIds.Add(commandId); + try { - if (_pendingCommands.ContainsKey(commandId)) - throw new InvalidOperationException($"Eve command '{commandId}' is already pending."); - _pendingCommands.Add(commandId, request); + var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); + _meshClient!.SubmitDocumentAsync( + _endpointId, + recordKey, + request, + _runtimeId, + "eve-unity") + .GetAwaiter() + .GetResult(); } - try { _commandOutbox!.Enqueue(request); } catch { - ForgetPendingCommand(commandId); + _pendingCommandIds.Remove(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; - _lifetime.Cancel(); - _commandOutbox?.Dispose(); - _commandOutbox = null; - _assetGeneration.Dispose(); + 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(); _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; @@ -458,82 +410,50 @@ namespace GameCult.Eve.UnityScene _contentState = null; _meshClient?.Dispose(); _meshClient = null; + _contentTransfer = null; + _assetBodyMappings = null; _baseSurface = null; _embeddedSurfaces.Clear(); - lock (_pendingCommands) - { - _pendingCommands.Clear(); - foreach (var subscription in _receiptSubscriptions.Values) - DisposeReceiptSubscription(subscription); - _receiptSubscriptions.Clear(); - } - lock (_presentationFinalityGate) - { - _deferredTerminalReceipts.Clear(); - _presentationBarrierVersion = -1; - } - _lifetime.Dispose(); + _pendingCommandIds.Clear(); + _publishedReceiptIds.Clear(); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { if (asset == null) throw new ArgumentNullException(nameof(asset)); - EnsureAssetPrepared(asset.AssetRef); - return _assetGeneration.Prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; + EnsureAssetLoaded(asset.AssetRef); + return _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)); - EnsureAssetPrepared(asset.AssetRef); - return _assetGeneration.NativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) + EnsureAssetLoaded(asset.AssetRef); + return _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 _assetGeneration.NativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!); + return _nativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!); } public bool TryGetRenderChannelLayer(string channel, out int layer) { - return _assetGeneration.RenderChannelLayers.TryGetValue(channel ?? "", out layer); + return _renderChannelLayers.TryGetValue(channel ?? "", out layer); } private void EnsureOpen() { if (_meshClient != null) return; - _meshClient = CreateMeshClient(new[] { _rendezvousEndpoint }, _authorityTrust); - } - - private CultMeshClient CreateMeshClient( - IReadOnlyList rendezvousEndpoints, - CultMeshAuthorityTrustPolicy authorityTrust) - { - return new CultMeshClient(new CultMeshClientOptions + _meshClient = new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = rendezvousEndpoints, - Discovery = EveUnityCultMeshConnectivity.Discovery(), - Sessions = new CultMeshSessionManagerOptions - { - Trust = authorityTrust ?? throw new ArgumentNullException(nameof(authorityTrust)) - }, - Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), - ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors(), + RendezvousEndpoints = new[] { _rendezvousEndpoint }, RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] { new CultMeshNativeQuicRealtimeTransportConnector() @@ -541,20 +461,15 @@ namespace GameCult.Eve.UnityScene }); } - 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) + private void ResolveAdvertisement(bool forceRefresh = false) { if (!forceRefresh && _advertisement != null && _advertisedSurface != null) return; - using var lease = await _meshClient! - .LeaseCollectionAsync(_target, cancellationToken); - var documents = await lease.Handle.LatestAsync(); - _advertisement = documents.FirstOrDefault(document => + using var advertisements = _meshClient! + .LeaseCollectionAsync(_endpointId) + .GetAwaiter() + .GetResult(); + _advertisement = advertisements.Handle.LatestAsync().GetAwaiter().GetResult().FirstOrDefault(document => string.Equals(document.ProviderId, _providerId, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"Provider '{_providerId}' did not publish an Eve advertisement."); _advertisedSurface = _advertisement.Surfaces.FirstOrDefault(surface => @@ -575,7 +490,7 @@ namespace GameCult.Eve.UnityScene var mappedRoot = _cachePath; var networkBodies = _meshClient!.BodyProvider( _advertisement.ProviderId, - _target, + _advertisement.ServiceId, new CultMeshSessionBodyProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(2) }); return new CultMeshBodyPublicationResolver(new CultMeshBodyTransportService( new ICultMeshBodyTransportAdapter[] @@ -621,7 +536,7 @@ namespace GameCult.Eve.UnityScene EveEntitySoaViewDocument document, CultMeshBodyPublicationDocument publication) { - EnsurePrepared(); + ResolveAdvertisement(); 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}'."); @@ -689,20 +604,19 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException( $"Eve provider '{_providerId}' does not advertise the Verse identity required for realtime state."); _realtimeLifetime = new CancellationTokenSource(); - 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)); + _realtimePump = Task.Run(() => PumpRealtimeEntityStateAsync( + _advertisement.VerseId, + _realtimeLifetime.Token)); } - private async Task PumpRealtimeEntityStateAsync(CancellationToken cancellationToken) + private async Task PumpRealtimeEntityStateAsync(string verseId, CancellationToken cancellationToken) { try { _realtimeSession = await _meshClient! - .ConnectRealtimeAsync(_target, cancellationToken) + .ConnectRealtimeAsync(verseId, cancellationToken) .ConfigureAwait(false); - TraceHotState($"realtime state connected transport={_realtimeSession.TransportId} target={_target}"); + TraceHotState($"realtime state connected transport={_realtimeSession.TransportId} verse={verseId}"); while (!cancellationToken.IsCancellationRequested) { var frame = await _realtimeSession.ReceiveAsync(cancellationToken).ConfigureAwait(false); @@ -906,78 +820,19 @@ namespace GameCult.Eve.UnityScene DateTimeOffset now) => publication.LivenessExpiresAtUnixMs > now.ToUnixTimeMilliseconds(); - private async Task RefreshSurfaceAsync(CancellationToken cancellationToken) + private void RefreshSurface() { - var surface = await _meshClient! - .ReadAsync(_target, _advertisedSurface!.RecordRef, cancellationToken); + var surface = _meshClient! + .ReadAsync(_endpointId, _advertisedSurface!.RecordRef) + .GetAwaiter() + .GetResult(); PublishBaseSurface(surface); } private void PublishBaseSurface(EveSurfaceDocument surface) { - 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; - } + _baseSurface = surface ?? throw new ArgumentNullException(nameof(surface)); + PublishComposedSurface(); } private void PublishEmbeddedSurface(string recordKey, EveSurfaceDocument surface) @@ -988,18 +843,13 @@ namespace GameCult.Eve.UnityScene PublishComposedSurface(); } - private void PublishComposedSurface(long? committedBaseVersion = null) + private void PublishComposedSurface() { - ApplyPreparedSurface(PrepareComposedSurface( - _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable.")), - committedBaseVersion); - } - - private PreparedSurface PrepareComposedSurface(EveSurfaceDocument baseSurface) - { - var surface = ComposeSurface(baseSurface, _embeddedSurfaces); + var surface = ComposeSurface( + _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable."), + _embeddedSurfaces); var interaction = RequireWorldInteraction(); - var document = new EveUnitySceneProviderSurfaceDocument( + CurrentSurfaceDocument = new EveUnitySceneProviderSurfaceDocument( surface, new EveUnitySceneProviderSurfaceAdvertisement( _advertisedSurface.SurfaceId, @@ -1011,25 +861,7 @@ 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( @@ -1076,13 +908,13 @@ namespace GameCult.Eve.UnityScene component.Style); } - private async Task EnsureLiveDocumentsAsync(CancellationToken cancellationToken) + private void EnsureLiveDocuments() { if (_liveDocumentsReady) return; DisposeLiveDocuments(); try { - await OpenLiveDocumentsAsync(cancellationToken); + OpenLiveDocuments(); _liveDocumentsReady = true; } catch @@ -1092,7 +924,7 @@ namespace GameCult.Eve.UnityScene } } - private async Task OpenLiveDocumentsAsync(CancellationToken cancellationToken) + private void OpenLiveDocuments() { var lowered = new EveUnitySceneSurfaceLowerer() .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); @@ -1115,23 +947,19 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException( "The playable world advertises an entity-view pointer without its logical body id."); _bodyResolver ??= CreateBodyResolver(); - await LeaseDocumentAsync(entityViewPointer, QueueEntityView, cancellationToken); - await LeaseCollectionAsync(publication => + LeaseDocument(entityViewPointer, QueueEntityView); + LeaseCollection(publication => { if (string.Equals(publication.BodyId, entityBodyId, StringComparison.Ordinal)) QueueBodyPublication(publication); - }, cancellationToken); + }); } foreach (var fieldRef in fieldRefs) - await LeaseDocumentAsync( - fieldRef, - fields => _liveDocuments.Enqueue(fields), - cancellationToken); + LeaseDocument(fieldRef, fields => _liveDocuments.Enqueue(fields)); - await LeaseDocumentAsync( + LeaseDocument( _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)) @@ -1141,108 +969,30 @@ namespace GameCult.Eve.UnityScene .ToArray(); foreach (var slot in embeddedSlots) { - await LeaseDocumentAsync( + LeaseDocument( slot.DocumentId, - surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface)), - cancellationToken); + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface))); } var inputCapabilityRef = FindComponentProp( CurrentSurfaceDocument.SurfaceDocument.Surface.Root, "inputCapability"); if (!string.IsNullOrWhiteSpace(inputCapabilityRef)) { - await LeaseDocumentAsync( + LeaseDocument( inputCapabilityRef, + document => _liveDocuments.Enqueue(document)); + } + var assetCatalogRef = RequireWorldInteraction().AssetManifestRecordRef; + if (!string.IsNullOrWhiteSpace(assetCatalogRef)) + { + LeaseDocument( + assetCatalogRef, document => _liveDocuments.Enqueue(document), - cancellationToken); + publishInitial: 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); + LeaseCollection( + receipt => _liveDocuments.Enqueue(receipt), + includeInitialSnapshot: false); } private void DisposeLiveDocuments() @@ -1254,32 +1004,29 @@ namespace GameCult.Eve.UnityScene _liveDocumentsReady = false; } - private async Task LeaseDocumentAsync( + private void LeaseDocument( string recordKey, Action publish, - CancellationToken cancellationToken, bool publishInitial = true) where TDocument : class { - var lease = await _meshClient!.LeaseDocumentAsync(_target, recordKey, cancellationToken); + var lease = _meshClient!.LeaseDocumentAsync(_endpointId, recordKey) + .GetAwaiter().GetResult(); _documentLeases.Add(lease); - if (publishInitial) publish(await lease.Handle.LatestAsync()); + if (publishInitial) publish(lease.Handle.Latest()); _documentWatches.Add(lease.Handle.Watch(publish)); } - private async Task LeaseCollectionAsync( + private void LeaseCollection( Action publish, - CancellationToken cancellationToken, bool includeInitialSnapshot = true) where TDocument : class { - var lease = await _meshClient!.LeaseCollectionAsync( - _target, - includeInitialSnapshot, - cancellationToken); + var lease = _meshClient!.LeaseCollectionAsync(_endpointId, includeInitialSnapshot) + .GetAwaiter().GetResult(); _documentLeases.Add(lease); if (includeInitialSnapshot) - foreach (var document in await lease.Handle.LatestAsync()) + foreach (var document in lease.Handle.LatestAsync().GetAwaiter().GetResult()) publish(document); _documentWatches.Add(lease.Handle.WatchChanges(change => { @@ -1308,341 +1055,44 @@ 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 async Task RefreshAssetCatalogAsync(CancellationToken cancellationToken) + private void RefreshAssetCatalog() { - var source = ResolveAssetSource( - _baseSurface ?? throw new InvalidOperationException("The provider surface must be available before its assets are resolved.")); - if (string.IsNullOrWhiteSpace(source.ManifestRecordRef)) + var interaction = RequireWorldInteraction(); + if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) return; - var candidate = await BuildAssetGenerationAsync(source, cancellationToken); - CommitAssetGeneration(candidate); + var catalog = _meshClient! + .ReadAsync(_endpointId, interaction.AssetManifestRecordRef) + .GetAwaiter() + .GetResult(); + PublishAssetCatalog(catalog); } - private async Task RefreshAssetsForSurfaceAsync( - EveSurfaceDocument surface, - AssetSource source, - long generation, - CancellationToken cancellationToken) + private void PublishAssetCatalog(EveAssetCatalogDocument catalog) { - 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.")); - } - - 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) + if (catalog.Version == CurrentAssetCatalogVersion) 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) - { + 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(); var selected = catalog.Assets .Select(asset => new SelectedAsset( asset, @@ -1661,178 +1111,96 @@ namespace GameCult.Eve.UnityScene $"Provider asset catalog '{catalog.CatalogId}' has no unity-scene/{CurrentBundlePlatform()} variant. " + $"Advertised variants: {string.Join(", ", advertisedVariants)}"); } - candidate.SetCatalogVersion(catalog.Version); - ReadCameraPolicies(candidate, selected.Select(selection => selection.Variant!)); + ReadCameraPolicies(selected.Select(selection => selection.Variant!)); foreach (var selection in selected) { var variant = selection.Variant!; - candidate.AssetSelections[selection.Asset.AssetRef] = selection; - candidate.BundleVariants[variant.Uri] = variant; - if (!candidate.BundleSelections.TryGetValue(variant.Uri, out var group)) + _assetSelections[selection.Asset.AssetRef] = selection; + _bundleVariants[variant.Uri] = variant; + if (!_bundleSelections.TryGetValue(variant.Uri, out var group)) { group = new List(); - candidate.BundleSelections[variant.Uri] = group; + _bundleSelections[variant.Uri] = group; } group.Add(selection); var metadata = MergeAssetMetadata(selection.Asset.Metadata, variant.Metadata); - candidate.NativeAssetMetadata[selection.Asset.AssetRef] = metadata; + _nativeAssetMetadata[selection.Asset.AssetRef] = metadata; if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && !string.IsNullOrWhiteSpace(role)) { - candidate.AssetSelections[role] = selection; - candidate.NativeAssetMetadata[role] = metadata; + _assetSelections[role] = selection; + _nativeAssetMetadata[role] = metadata; } } + + CurrentAssetCatalogVersion = catalog.Version; } - private void QueueAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog) + private void EnsureAssetLoaded(string assetRef) { - if (catalog == null) throw new ArgumentNullException(nameof(catalog)); - if (_assetGeneration.Source.RequiredCatalogVersion > 0) - { - _pendingAssetCatalog = null; + if (_nativeAssets.ContainsKey(assetRef) || !_assetSelections.TryGetValue(assetRef, out var selection)) return; - } - _pendingAssetCatalog = new PendingAssetCatalogUpdate( - sourceIdentity, - catalog); - if (_assetCatalogUpdateRunning) return; - _assetCatalogUpdateRunning = true; - _ = DrainAssetCatalogUpdatesAsync(); + EnsureBundleLoaded(selection.Variant!.Uri); } - private async Task DrainAssetCatalogUpdatesAsync() + private void EnsureBundleLoaded(string uri) { - 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)) + if (_loadedBundleUris.Contains(uri)) return; + if (!_bundleVariants.TryGetValue(uri, out var variant)) throw new InvalidOperationException($"Provider asset bundle '{uri}' is not present in the selected runtime catalog."); - if (!candidate.LoadingBundleUris.Add(uri)) + if (!_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)) - await EnsureBundleLoadedAsync(candidate, dependency, cancellationToken); + EnsureBundleLoaded(dependency); var groupElapsed = Stopwatch.StartNew(); - var descriptor = await AssetMeshClient(candidate) - .ReadAsync(candidate.Source.Target, uri, cancellationToken); + var descriptor = _meshClient!.ReadAsync(_endpointId, uri) + .GetAwaiter() + .GetResult(); TraceStartup("asset-manifest", groupElapsed); - var bundle = await LoadVerifiedBundleAsync(candidate, descriptor, variant, cancellationToken); + var bundle = LoadVerifiedBundle(descriptor, variant); TraceStartup("asset-transfer-and-bundle-load", groupElapsed); if (bundle == null) throw new InvalidOperationException($"Unity could not load provider asset bundle '{uri}'."); - candidate.AssetBundles.Add(bundle); - candidate.LoadedBundlesByUri[uri] = bundle; + _assetBundles.Add(bundle); var bundleAssetNames = bundle.GetAllAssetNames(); - foreach (var selection in candidate.BundleSelections[uri]) + foreach (var selection in _bundleSelections[uri]) { var bundleAssetName = ResolveBundleAssetName(bundleAssetNames, selection.Variant!.AssetKey); var value = bundleAssetName == null ? null - : await LoadBundleAssetAsync(bundle, bundleAssetName, selection.Asset.AssetKind, cancellationToken); + : LoadBundleAsset(bundle, bundleAssetName, selection.Asset.AssetKind); if (value == null) throw new InvalidOperationException( $"Provider asset '{selection.Asset.AssetRef}' advertises missing Unity bundle asset " + $"'{selection.Variant.AssetKey}'. Available assets: " + string.Join(", ", bundleAssetNames)); - candidate.NativeAssets[selection.Asset.AssetRef] = value; + _nativeAssets[selection.Asset.AssetRef] = value; if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && !string.IsNullOrWhiteSpace(role)) - candidate.NativeAssets[role] = value; + _nativeAssets[role] = value; if (value is GameObject prefab) { - candidate.Prefabs[selection.Asset.AssetRef] = prefab; - if (!string.IsNullOrWhiteSpace(role)) candidate.Prefabs[role] = prefab; + _prefabs[selection.Asset.AssetRef] = prefab; + if (!string.IsNullOrWhiteSpace(role)) _prefabs[role] = prefab; } } - candidate.LoadedBundleUris.Add(uri); + _loadedBundleUris.Add(uri); TraceStartup("bundle-assets", groupElapsed); } finally { - candidate.LoadingBundleUris.Remove(uri); + _loadingBundleUris.Remove(uri); } } - private static async Task LoadBundleAssetAsync( - AssetBundle bundle, - string assetName, - string assetKind, - CancellationToken cancellationToken) + private static UnityEngine.Object? LoadBundleAsset(AssetBundle bundle, string assetName, string assetKind) { - var request = string.Equals(assetKind, "sprite", StringComparison.Ordinal) - ? bundle.LoadAssetAsync(assetName) - : bundle.LoadAssetAsync(assetName); - await AwaitUnityAsync(request, cancellationToken); - return request.asset; + return string.Equals(assetKind, "sprite", StringComparison.Ordinal) + ? bundle.LoadAsset(assetName) + : bundle.LoadAsset(assetName); } private sealed class SelectedAsset @@ -1847,115 +1215,6 @@ 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 => @@ -1972,8 +1231,9 @@ namespace GameCult.Eve.UnityScene return merged; } - private static void ReadCameraPolicies(AssetGeneration candidate, IEnumerable variants) + private void ReadCameraPolicies(IEnumerable variants) { + _renderChannelLayers.Clear(); foreach (var variant in variants) { foreach (var pair in variant.Metadata) @@ -1986,14 +1246,12 @@ 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) - candidate.RenderChannelLayers[channel] = layer; + _renderChannelLayers[channel] = layer; } } } - private long CurrentAssetCatalogVersion => _assetGeneration.CatalogVersion; - - private string CurrentAssetSourceIdentity => _assetGeneration.SourceIdentity; + private long CurrentAssetCatalogVersion { get; set; } = -1; private static string CurrentBundlePlatform() { @@ -2003,38 +1261,33 @@ namespace GameCult.Eve.UnityScene : Application.platform.ToString(); } - private CultMeshContentTransferService ContentTransfer(AssetGeneration candidate) + private CultMeshContentTransferService ContentTransfer() { - if (candidate.ContentTransfer != null) - return candidate.ContentTransfer; - if (_advertisement == null) - throw new InvalidOperationException("The Eve provider advertisement must be prepared before content transfer."); + ResolveAdvertisement(); + if (_contentTransfer != null) + return _contentTransfer; 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"); - candidate.BodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); + _assetBodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); _contentState ??= new CultCache( CultMesh.CreateCultCacheDocumentRegistry(typeof(CultMeshContentTransferStateDocument))); - candidate.ContentTransfer = new CultMeshContentTransferService( + _contentTransfer = new CultMeshContentTransferService( _contentState, new[] { - AssetMeshClient(candidate).ContentProvider( - candidate.Source.ProviderId, - candidate.Source.Target) + _meshClient!.ContentProvider( + _advertisement.ProviderId, + _advertisement.ServiceId) }, new CultMeshContentTransferOptions(cacheRoot), - candidate.BodyMappings); - return candidate.ContentTransfer; + _assetBodyMappings); + return _contentTransfer; } - private async Task LoadVerifiedBundleAsync( - AssetGeneration candidate, - CultMeshCdnArtifactManifest manifest, - EveAssetVariant variant, - CancellationToken cancellationToken) + private AssetBundle? LoadVerifiedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant) { var hash = NormalizeContentHash(variant.ContentHash); if (manifest.SizeBytes != variant.SizeBytes) @@ -2046,10 +1299,12 @@ namespace GameCult.Eve.UnityScene var now = DateTimeOffset.UtcNow; var network = NetworkArtifactDescriptor(manifest, now, TimeSpan.FromMinutes(5)); var elapsed = Stopwatch.StartNew(); - var mapped = await ContentTransfer(candidate) - .FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5), cancellationToken); + var mapped = ContentTransfer() + .FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5)) + .GetAwaiter() + .GetResult(); TraceStartup("content-materialization", elapsed); - var validationRequest = new CultMeshBodyValidationRequest + var request = new CultMeshBodyValidationRequest { BodyId = network.BodyId, SchemaId = network.SchemaId, @@ -2063,28 +1318,34 @@ namespace GameCult.Eve.UnityScene var transport = new CultMeshBodyTransportService( new ICultMeshBodyTransportAdapter[] { - new CultMeshMappedBodyAdapter(candidate.BodyMappings!), - new CultMeshNetworkBodyAdapter(_ => File.ReadAllBytes(mapped.VerifiedPath)) + new CultMeshMappedBodyAdapter(_assetBodyMappings!), + new CultMeshNetworkBodyAdapter(_ => ReadVerifiedArtifactBytes(manifest)) }, descriptor => string.Equals(descriptor.BodyId, manifest.ArtifactId, StringComparison.Ordinal) && string.Equals(descriptor.SemanticHash, hash, StringComparison.Ordinal)); - 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; + 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; } - private static async Task AwaitUnityAsync(AsyncOperation operation, CancellationToken cancellationToken) + private byte[] ReadVerifiedArtifactBytes(CultMeshCdnArtifactManifest manifest) { - while (!operation.isDone) - { - cancellationToken.ThrowIfCancellationRequested(); - await Task.Yield(); - } - cancellationToken.ThrowIfCancellationRequested(); + var path = ContentTransfer().FetchAsync(manifest).GetAwaiter().GetResult(); + return File.ReadAllBytes(path); } private static CultMeshBodyDescriptor NetworkArtifactDescriptor( @@ -2114,103 +1375,8 @@ namespace GameCult.Eve.UnityScene private void PublishReceipt(EveCommandReceiptDocument receipt) { - 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); - } + if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) + return; CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, @@ -2231,93 +1397,11 @@ namespace GameCult.Eve.UnityScene receipt.Navigation.ProviderId, receipt.Navigation.SurfaceId, receipt.Navigation.SurfaceKind, - 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(); - } + receipt.Navigation.RendezvousEndpoints))); + if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || + string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || + string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) + _pendingCommandIds.Remove(receipt.CommandId); } private EveWorldInteractionAdvertisement RequireWorldInteraction() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index 3d356f3..3064459 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -3,7 +3,6 @@ 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; @@ -40,18 +39,10 @@ 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(); @@ -94,9 +85,7 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string verseId = "", string requiredSurfaceKind = "interactive-world", - string clientRuntimeId = "eve-unity", - CultMeshAuthorityTrustPolicy? authorityTrust = null, - CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null) + string clientRuntimeId = "eve-unity") { if (_bridge != null || _preparation != null) throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); @@ -108,17 +97,11 @@ 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() @@ -126,21 +109,13 @@ namespace GameCult.Eve.UnityScene if (_bridge != null) return Task.CompletedTask; if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted) - { - _preparationLifetime?.Dispose(); - _preparationLifetime = new CancellationTokenSource(); - _preparation = PrepareTransportAsync(_preparationLifetime.Token); - } + _preparation = PrepareTransportAsync(); return _preparation; } public void Disconnect() { - if (_connectionLeaseCount <= 0) - return; - _connectionLeaseCount--; - if (_connectionLeaseCount == 0) - _bridge?.Disconnect(); + _bridge?.Disconnect(); } public async Task NavigateAsync(EveUnitySceneNavigationTarget target) @@ -151,107 +126,29 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(target.SurfaceId)) throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target)); - 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; + ReleaseTransport(); + var navigationEndpoint = target.RendezvousEndpoints + .FirstOrDefault(endpoint => !string.IsNullOrWhiteSpace(endpoint)); + if (!string.IsNullOrWhiteSpace(navigationEndpoint)) + rendezvousEndpoint = navigationEndpoint; verseFilter = target.VerseId; providerFilter = target.ProviderId; surfaceFilter = target.SurfaceId; - surfaceKind = 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); + surfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) ? "interactive-world" : target.SurfaceKind; + await PrepareAsync(); } public void Refresh() { - var activeBridge = _navigationRouteCommitted - ? Bridge - : _previousProvider?.Bridge ?? Bridge; - if (activeBridge.IsConnected) - activeBridge.Refresh(); + if (Bridge.IsConnected) + Bridge.Refresh(); else - activeBridge.Connect(); + Bridge.Connect(); } public void Submit(EveSurfaceCommandRequest 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); + Bridge.Submit(request); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) @@ -287,7 +184,6 @@ namespace GameCult.Eve.UnityScene private void ReleaseTransport() { - _preparationLifetime?.Cancel(); if (_bridge != null) { _bridge.DocumentAvailable -= ForwardDocument; @@ -295,24 +191,16 @@ 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)) @@ -340,7 +228,7 @@ namespace GameCult.Eve.UnityScene } } - private async Task PrepareTransportAsync(CancellationToken cancellationToken) + private async Task PrepareTransportAsync() { var elapsed = Stopwatch.StartNew(); if (_transport != null) @@ -348,183 +236,38 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - var prepared = await PrepareCandidateAsync( - new[] { rendezvousEndpoint }, - providerFilter, - surfaceFilter, - surfaceKind, - 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, + Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync( 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 _)) { } + 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"); } - 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)) @@ -549,57 +292,5 @@ 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 7772dbc..62e6837 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Mesh; @@ -15,23 +14,23 @@ namespace GameCult.Eve.UnityScene { public EveUnityCultMeshProviderSelection( string rendezvousEndpoint, + string endpointId, string verseId, - string authorityRuntimeId, string providerId, string surfaceId, string surfaceKind) { RendezvousEndpoint = rendezvousEndpoint ?? ""; + EndpointId = endpointId ?? ""; VerseId = verseId ?? ""; - AuthorityRuntimeId = authorityRuntimeId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; } public string RendezvousEndpoint { get; } + public string EndpointId { get; } public string VerseId { get; } - public string AuthorityRuntimeId { get; } public string ProviderId { get; } public string SurfaceId { get; } public string SurfaceKind { get; } @@ -39,22 +38,21 @@ namespace GameCult.Eve.UnityScene public sealed class EveUnityCultMeshProviderDiscovery { - private readonly CultMeshAuthorityTrustPolicy _authorityTrust; - - public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null) - { - _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( - CultMeshAuthorityTrustMode.AuthenticatedRemote); - } + public EveUnityCultMeshProviderSelection Discover( + string rendezvousEndpoint, + string providerId = "", + string surfaceId = "", + string surfaceKind = "interactive-world", + string verseId = "") => + DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId) + .GetAwaiter().GetResult(); public async Task DiscoverAsync( string rendezvousEndpoint, string providerId = "", string surfaceId = "", string surfaceKind = "interactive-world", - string verseId = "", - CancellationToken cancellationToken = default, - string requiredAuthorityRuntimeId = "") + string verseId = "") { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); @@ -62,14 +60,13 @@ namespace GameCult.Eve.UnityScene CultMeshVerseCatalogResponseMessage response; try { - response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync( + response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync( rendezvousEndpoint, new CultMeshVerseCatalogRequestMessage { VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId }, TransportVersion = "cultmesh.v0" }); - cancellationToken.ThrowIfCancellationRequested(); } catch (Exception error) { @@ -94,74 +91,53 @@ namespace GameCult.Eve.UnityScene var observed = new List(); using var mesh = new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = new[] { rendezvousEndpoint }, - Discovery = EveUnityCultMeshConnectivity.Discovery(), - Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust }, - Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), - ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors() + RendezvousEndpoints = new[] { rendezvousEndpoint } }); foreach (var candidate in candidates) { - foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds( - candidate.AuthorityRuntimeIds, - requiredAuthorityRuntimeId)) + try { - 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; + 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; - 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}"); - } + 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}"); } } - var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'"; + var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'"; 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 671a17f..ba5091b 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,8 +16,7 @@ "GameCult.Caching.MessagePack.dll", "GameCult.Mesh.dll", "GameCult.Mesh.Quic.Native.dll", - "GameCult.Networking.dll", - "GameCult.Networking.WebSockets.dll" + "GameCult.Networking.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 ea39b34..ffe1791 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -30,13 +30,8 @@ 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) { @@ -49,12 +44,7 @@ namespace GameCult.Eve.UnityScene BindNavigation(); } - private void OnDestroy() - { - UnbindNavigation(); - _ownedPresentation?.Dispose(); - _ownedPresentation = null; - } + private void OnDestroy() => UnbindNavigation(); private void BindNavigation() { @@ -87,67 +77,12 @@ 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(); - 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}"); + Mount(); } finally { @@ -155,255 +90,6 @@ 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(); @@ -557,21 +243,4 @@ 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 f8d7182..4fe2ebb 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -238,12 +238,6 @@ namespace GameCult.Eve.UnityScene public interface IEveUnityNavigableProvider { Task NavigateAsync(EveUnitySceneNavigationTarget target); - - void CommitNavigation(); - - void FinalizeNavigation(); - - void RollbackNavigation(); } public sealed class EveUnitySceneNavigationTarget @@ -253,11 +247,9 @@ namespace GameCult.Eve.UnityScene string providerId, string surfaceId, string surfaceKind, - IReadOnlyList? rendezvousEndpoints = null, - string authorityRuntimeId = "") + IReadOnlyList? rendezvousEndpoints = null) { VerseId = verseId ?? ""; - AuthorityRuntimeId = authorityRuntimeId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; @@ -265,7 +257,6 @@ 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 b14f549..77b9d1f 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -172,11 +172,7 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("exposureAdaptation", "progressive"), ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f), ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f), - worldRoot.GetProp("colorGradingSpace"), - worldRoot.GetProp("assetProviderId"), - worldRoot.GetProp("assetVerseId"), - worldRoot.GetProp("assetAuthorityRuntimeId"), - ParseStringList(worldRoot.GetProp("assetRendezvousEndpoints"))); + worldRoot.GetProp("colorGradingSpace")); } private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) => @@ -274,7 +270,7 @@ namespace GameCult.Eve.UnityScene private static IReadOnlyList ParseStringList(string value) { return (value ?? "") - .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Split(',') .Select(part => part.Trim()) .Where(part => !string.IsNullOrWhiteSpace(part)) .Distinct(StringComparer.Ordinal) @@ -447,11 +443,7 @@ namespace GameCult.Eve.UnityScene string exposureAdaptation = "progressive", float exposureSpeedUp = 2f, float exposureSpeedDown = 1f, - string colorGradingSpace = "", - string assetProviderId = "", - string assetVerseId = "", - string assetAuthorityRuntimeId = "", - IReadOnlyList? assetRendezvousEndpoints = null) + string colorGradingSpace = "") { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -515,10 +507,6 @@ namespace GameCult.Eve.UnityScene ExposureSpeedUp = exposureSpeedUp; ExposureSpeedDown = exposureSpeedDown; ColorGradingSpace = colorGradingSpace ?? ""; - AssetProviderId = assetProviderId ?? ""; - AssetVerseId = assetVerseId ?? ""; - AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? ""; - AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty(); } public string WorldRootId { get; } @@ -537,14 +525,6 @@ 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 fb4481b..34fa300 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs @@ -15,20 +15,16 @@ namespace GameCult.Eve.UnityScene var document = host.GetComponent(); if (document != null) return document; - 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 theme = Resources.Load(ThemeResource); + if (theme == null) + throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing."); - 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; - } + 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; 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 376bc8a..f45bc97 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -2,15 +2,11 @@ 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; @@ -18,324 +14,6 @@ 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() { @@ -348,177 +26,11 @@ 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() { @@ -646,16 +158,29 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem() + public void PublishingCatalogRegistersBundlesWithoutMaterializingThem() { var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( "CurrentBundlePlatform", BindingFlags.Static | BindingFlags.NonPublic); - var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( - "ConfigureAssetCatalog", - 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); Assert.That(platformMethod, Is.Not.Null); - Assert.That(configureMethod, 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"); var variant = new EveAssetVariant( "unity-scene", (string)platformMethod!.Invoke(null, Array.Empty())!, @@ -670,229 +195,11 @@ 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); - configureMethod!.Invoke(null, new[] { candidate, catalog }); + publishMethod!.Invoke(transport, new object[] { catalog }); - Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero); - Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1)); - ((IDisposable)candidate).Dispose(); + Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero); + Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).Count, Is.EqualTo(1)); } - - [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 7f83143..ffbda32 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -3,7 +3,6 @@ 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; @@ -33,7 +32,6 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "aetheria", "aetheria", - "aetheria", "aetheria.pilot"); }); } @@ -69,7 +67,6 @@ 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); @@ -105,7 +102,6 @@ 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); @@ -135,7 +131,6 @@ 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)!; @@ -166,7 +161,6 @@ namespace GameCult.Eve.UnityScene.Tests "cultnet://127.0.0.1:3075", "provider", "provider", - "provider", "provider.pilot"); var view = EntityLeaseDocument(); view.Sequence = 0; @@ -582,10 +576,6 @@ 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); @@ -1175,9 +1165,6 @@ 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", @@ -1199,153 +1186,7 @@ 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, 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); + Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(2)); } finally { @@ -2734,10 +2575,6 @@ 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, @@ -3443,7 +3280,6 @@ namespace GameCult.Eve.UnityScene.Tests IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, IEveUnityInputCapabilitySource, - IEveUnitySceneProviderSurfaceDocumentConnection, IEveUnityNavigableProvider { public List Submitted { get; } = new List(); @@ -3452,28 +3288,6 @@ 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; @@ -3495,9 +3309,7 @@ namespace GameCult.Eve.UnityScene.Tests new EveInputCapabilityDocument(); EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => - _failSurfaceRead - ? throw new InvalidOperationException("Deliberate candidate surface read failure.") - : CurrentSurfaceDocument; + CurrentSurfaceDocument; public event Action? DocumentAvailable; @@ -3541,47 +3353,8 @@ 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 f5dcbe0..1538e10 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.134", + "version": "0.3.105", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.56", + "org.gamecult.cultlib": "1.0.46", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.6", - "org.gamecult.eve.unity-uitoolkit": "0.1.9", + "org.gamecult.eve.surface": "0.3.1", + "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "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 f5397a2..f6e4fcd 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -17,7 +17,6 @@ namespace GameCult.Eve.UnityUIToolkit private Vector2 _inventoryDragStart; private bool _inventoryPointerMoved; private bool _suppressInventoryClick; - private readonly List _inventoryPreviewCells = new(); public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null) { @@ -46,7 +45,6 @@ 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; @@ -132,8 +130,6 @@ namespace GameCult.Eve.UnityUIToolkit element.style.flexWrap = Wrap.Wrap; return element; } - case "scroll": - return new ScrollView(ScrollViewMode.Vertical); case "pane": case "modal": case "card": @@ -277,7 +273,6 @@ namespace GameCult.Eve.UnityUIToolkit return; if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink)) _inventoryDragSource = null; - ClearInventoryPreview(); }); return grid; } @@ -296,7 +291,6 @@ namespace GameCult.Eve.UnityUIToolkit _inventoryDragSource = component; _inventoryDragStart = evt.position; _inventoryPointerMoved = false; - ClearInventoryPreview(); item.CapturePointer(evt.pointerId); evt.StopPropagation(); }); @@ -306,15 +300,7 @@ 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 => { @@ -329,7 +315,6 @@ namespace GameCult.Eve.UnityUIToolkit TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink); _inventoryDragSource = null; _suppressInventoryClick = true; - ClearInventoryPreview(); } evt.StopPropagation(); }); @@ -421,50 +406,6 @@ 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; @@ -538,26 +479,18 @@ namespace GameCult.Eve.UnityUIToolkit }) .Where(option => !string.IsNullOrWhiteSpace(option.Value)) .ToArray(); - 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 labels = options.Select(option => option.Label).ToList(); var selectedValue = component.GetProp("value"); var selectedIndex = Array.FindIndex(options, option => string.Equals(option.Value, selectedValue, StringComparison.Ordinal)); var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex)); + field.SetEnabled(!ParseBool(component.GetProp("disabled"))); field.RegisterValueChangedCallback(change => { - var index = field.index; - if (index < 0 || index >= options.Length) + var option = options.FirstOrDefault(candidate => + string.Equals(candidate.Label, change.newValue, StringComparison.Ordinal)); + if (option == null) return; - var option = options[index]; var payload = new Dictionary(component.Props, StringComparer.Ordinal) { ["value"] = option.Value @@ -667,12 +600,6 @@ 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)) @@ -749,14 +676,6 @@ 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 20b98bb..93b83c5 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using GameCult.Eve.Surface; using GameCult.Mesh; using NUnit.Framework; @@ -52,69 +51,6 @@ 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