diff --git a/.gitignore b/.gitignore index 2c9c34e..975deb4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ TestProject/UserSettings/ obj/ *.csproj *.sln +packages/org.gamecult.eve.surface/bin~/ +packages/org.gamecult.eve.surface/obj~/ +packages/org.gamecult.eve.surface/Directory.Build.props.meta diff --git a/README.md b/README.md index 3e36f56..2f76872 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ assembly is part of the client. `EveUnityCultMeshPlayableWorldProvider` is the shipped composition root. Its provider, surface, and Verse filters default to empty, so a client can select by -the generic `interactive-world` contract alone. +the generic `interactive-world` contract alone. After selection, Unity retains +the rendezvous address, Verse ID, and stable provider identity only. `CultMeshClient` owns +physical route discovery, reconnection, typed live-resource leases, command +submission, and content/body sessions. ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\run-package-tests.ps1 diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 8cb8b1d..8d8cef4 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? ""; var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? ""; - var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ?? - Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc"); + var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ?? + Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}"); var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ?? Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png"); var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ?? @@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests provider = root.AddComponent(); provider.Configure( rendezvousEndpoint, - replicaPath, + cacheDirectory, providerId, surfaceId, requiredSurfaceKind: "interactive-world", @@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); var quicProof = ReceiveAdvertisedRealtimeFrameAsync( rendezvousEndpoint, - provider.Selection.VerseId); + provider.Selection.VerseId, + provider.Selection.AuthorityRuntimeId); var quicDeadline = Time.realtimeSinceStartup + 15f; while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) yield return new WaitForSecondsRealtime(0.05f); @@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests private static async Task ReceiveAdvertisedRealtimeFrameAsync( string rendezvousEndpoint, - string verseId) + string verseId, + string authorityRuntimeId) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var client = new CultMeshClient(new CultMeshClientOptions @@ -1211,7 +1213,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests new CultMeshNativeQuicRealtimeTransportConnector() } }); - using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token); + using var session = await client.ConnectRealtimeAsync( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + timeout.Token); return await session.ReceiveAsync(timeout.Token); } diff --git a/ReleaseConsumerProject/Packages/manifest.json b/ReleaseConsumerProject/Packages/manifest.json index 29c8b9c..b82d19d 100644 --- a/ReleaseConsumerProject/Packages/manifest.json +++ b/ReleaseConsumerProject/Packages/manifest.json @@ -6,11 +6,11 @@ "com.unity.modules.physics": "1.0.0", "com.unity.modules.uielements": "1.0.0", "com.unity.render-pipelines.universal": "17.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.43", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e", "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", - "org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4", - "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.102", - "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2" + "org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825", + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9", + "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "testables": [ "org.gamecult.eve.unity-scene", diff --git a/ReleaseConsumerProject/Packages/packages-lock.json b/ReleaseConsumerProject/Packages/packages-lock.json index 9ad97d9..8ad82a2 100644 --- a/ReleaseConsumerProject/Packages/packages-lock.json +++ b/ReleaseConsumerProject/Packages/packages-lock.json @@ -119,11 +119,11 @@ } }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.43", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e", "depth": 0, "source": "git", "dependencies": {}, - "hash": "f67f5122ed1bd11da016e7b820ed60145ccd0299" + "hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e" }, "org.gamecult.eve.plugin-fields": { "version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", @@ -135,37 +135,37 @@ "hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0" }, "org.gamecult.eve.surface": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4", + "version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.8" + "org.gamecult.cultlib": "1.0.56" }, - "hash": "e08fa08335f99e9edddeb706912eecfad07cb281" + "hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825" }, "org.gamecult.eve.unity-scene": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.102", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.43", + "org.gamecult.cultlib": "1.0.56", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.4", - "org.gamecult.eve.unity-uitoolkit": "0.1.2", + "org.gamecult.eve.surface": "0.3.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" }, - "hash": "ddd2c5871f935978a0640d43cb58e18579523125" + "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "org.gamecult.eve.unity-uitoolkit": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.eve.surface": "0.2.4" + "org.gamecult.eve.surface": "0.3.6" }, - "hash": "44390549775ec535ee31fc60a5bc594f372f4147" + "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "com.unity.modules.assetbundle": { "version": "1.0.0", diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 8cb8b1d..8d8cef4 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? ""; var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? ""; - var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ?? - Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc"); + var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ?? + Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}"); var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ?? Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png"); var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ?? @@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests provider = root.AddComponent(); provider.Configure( rendezvousEndpoint, - replicaPath, + cacheDirectory, providerId, surfaceId, requiredSurfaceKind: "interactive-world", @@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); var quicProof = ReceiveAdvertisedRealtimeFrameAsync( rendezvousEndpoint, - provider.Selection.VerseId); + provider.Selection.VerseId, + provider.Selection.AuthorityRuntimeId); var quicDeadline = Time.realtimeSinceStartup + 15f; while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) yield return new WaitForSecondsRealtime(0.05f); @@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests private static async Task ReceiveAdvertisedRealtimeFrameAsync( string rendezvousEndpoint, - string verseId) + string verseId, + string authorityRuntimeId) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var client = new CultMeshClient(new CultMeshClientOptions @@ -1211,7 +1213,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests new CultMeshNativeQuicRealtimeTransportConnector() } }); - using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token); + using var session = await client.ConnectRealtimeAsync( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + timeout.Token); return await session.ReceiveAsync(timeout.Token); } diff --git a/TestProject/Packages/manifest.json b/TestProject/Packages/manifest.json index 1f31a7a..157057e 100644 --- a/TestProject/Packages/manifest.json +++ b/TestProject/Packages/manifest.json @@ -7,7 +7,7 @@ "com.unity.modules.uielements": "1.0.0", "org.gamecult.cultlib": "file:../../artifacts/test-dependencies/org.gamecult.cultlib", "org.gamecult.eve.plugin-fields": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields", - "org.gamecult.eve.surface": "file:../../packages/org.gamecult.eve.surface", + "org.gamecult.eve.surface": "file:../../../Eve/packages/org.gamecult.eve.surface", "org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene", "org.gamecult.eve.unity-uitoolkit": "file:../../packages/org.gamecult.eve.unity-uitoolkit" }, diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 8034b4d..d631673 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -133,11 +133,11 @@ } }, "org.gamecult.eve.surface": { - "version": "file:../../packages/org.gamecult.eve.surface", + "version": "file:../../../Eve/packages/org.gamecult.eve.surface", "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.8" + "org.gamecult.cultlib": "1.0.56" } }, "org.gamecult.eve.unity-scene": { @@ -145,9 +145,10 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.14", + "org.gamecult.cultlib": "1.0.56", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.2", + "org.gamecult.eve.surface": "0.3.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" @@ -158,7 +159,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.eve.surface": "0.1.0" + "org.gamecult.eve.surface": "0.3.6" } }, "com.unity.modules.assetbundle": { diff --git a/docs/aetheria-released-package-witness.md b/docs/aetheria-released-package-witness.md index b38f763..6446cec 100644 --- a/docs/aetheria-released-package-witness.md +++ b/docs/aetheria-released-package-witness.md @@ -4,26 +4,26 @@ The generic `ReleaseConsumerProject` is pinned to released Git packages: -- `org.gamecult.eve.unity-scene` `0.3.100`, commit - `3632bfb3354065b78a90b921d7025c4c3fa92be4`, and - `org.gamecult.eve.surface` `0.2.4`, commit - `e08fa08335f99e9edddeb706912eecfad07cb281`; +- `org.gamecult.eve.unity-scene` `0.3.134`, commit + `d1663b36d12b60ff5c7249135eff48edf6227ca9`, and + `org.gamecult.eve.surface` `0.3.6`, commit + `96839ad34c8d464ef622d8bbdd5d277e1ca9d825`; - `org.gamecult.eve.plugin-fields` `0.2.3`, commit `c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`; -- `org.gamecult.eve.unity-uitoolkit` `0.1.1`, commit - `4d0cbe0185bdc4fc65eb63503a7c5cb578539669`; -- `org.gamecult.cultlib` `1.0.43`, commit - `f67f5122ed1bd11da016e7b820ed60145ccd0299`. +- `org.gamecult.eve.unity-uitoolkit` `0.1.9`, commit + `d1663b36d12b60ff5c7249135eff48edf6227ca9`; +- `org.gamecult.cultlib` `1.0.56`, commit + `334e60f1928b4212a29dd8b0d19b2c099fe6365e`. The hand-run integration gates are the cold lowering proof and the warm full-session proof: ```powershell pwsh -File .\scripts\run-aetheria-daemon-world-witness.ps1 ` - -CultLibRoot E:\Projects\CultLib-codex-cultmesh-reliability ` + -CultLibRoot E:\Projects\CultLib ` -CacheState cold ` - -AssetCacheDirectory artifacts\cultlib-1043-scene-03100-cold-cache ` - -OutputDirectory artifacts\cultlib-1043-scene-03100-cold ` + -AssetCacheDirectory artifacts\admitted-release-cold-cache ` + -OutputDirectory artifacts\admitted-release-cold ` -SkipAssetBundleBuild ``` @@ -31,9 +31,13 @@ Both connect directly to the Aetheria daemon. Control, subscriptions, commands, and receipts use `cultnet+tcp`; provider files use `cultmesh-content+tcp`; same-machine entity state uses a mapped SoA body; and the released native QUIC client receives the advertised latest-only realtime body. No Odin hop or -Aetheria-specific gameplay code participates in the Unity runtime. +Aetheria-specific gameplay code participates in the Unity runtime. This +admission pass proves the package graph with the isolated EditMode consumer; it +does not claim a new rendered end-to-end witness. -The current cold proof is +## Historical witness ledger + +The retained cold proof is `artifacts/cultlib-1043-scene-03100-cold5/runtime-witness.cold.json`. It began with no bodies or partials and passed in 57.8 seconds end to end (14.9 seconds inside PlayMode). The client materialized nine requested bundles totaling @@ -45,7 +49,7 @@ unloaded. This cold profile owns content transfer, world lowering, fog and Stardust execution, and pilot/map camera isolation. It deliberately issues no gameplay command into the paused Terminus fixture. -The current warm full-session proof is +The retained warm full-session proof is `artifacts/cultlib-1043-scene-03100-warm/runtime-witness.warm.json`. It passed in 70.7 seconds end to end (27.9 seconds inside PlayMode). The generic client docks, buys and sells one typed item, resumes and undocks, moves, targets, aims, holds @@ -63,8 +67,6 @@ visual-lever and shader-calibration issues, not concealed transport failures. The map capture is clean and contains only map-channel glyphs on its dark map background. -## Historical witness ledger - An earlier released-package cold world witness passed with: - `org.gamecult.eve.unity-scene` `0.3.63`, commit diff --git a/docs/authority-map.md b/docs/authority-map.md index ac00b6d..3be5f6c 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -9,6 +9,9 @@ importing provider product code into the generic runtime. EveUnity owns Unity scene projection, `GameObject` lifecycle, camera and input drivers, asset-provider hooks, package release, runtime tests, and captures. +`CultMeshClient` owns physical route selection, reconnection, shared typed +resource sessions, and their lease lifetime. Unity receives stable Verse and +provider identities and cannot preserve a physical route as gameplay authority. ## Inputs @@ -40,6 +43,10 @@ presentation counters, and receipt display state are projections. They are not world truth. Temporal targets reset when the volume program, node, or render size changes and can never become provider state. +The optional Unity cache directory contains verified content-transfer state and +mapped body bytes only. It is disposable derived data, not a CultCache replica +of provider gameplay documents. + Camera-relative field viewports have one generic resolver. The advertised viewport frame owns its extent, spatial-cell scale, raster texels per cell, and snap policy; fog volumes and stateless particle programs consume the identical @@ -65,6 +72,18 @@ temporal pass must also advertise its current-sample, history, previous-view-projection, and reset-history ports; partial temporal programs fail closed. +Unity code may not open provider snapshot sessions, schema clients, write +forwarders, subscriptions, or replica shards against a discovered physical +route. Reads, watches, content/body transfer, realtime frames, and command +submissions share the same stable-identity `CultMeshClient` owner. +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 `viewportTextureScaleBindings`; EveUnity derives the vector from the active @@ -86,11 +105,14 @@ modes into one camera opinion or infer a product camera from entity kind. ## Shared Paths -Initial connect discovers a Verse endpoint and selects a provider surface by -semantic kind. Refresh, reconnect, and terminal-receipt reconciliation consume -that provider's surface documents. Movement, focus, target, and action input all -become `gamecult.eve.command_invocation.v1` through the provider-advertised -command boundary. +Initial preparation asks one rendezvous for Verses, selects an advertised +authority runtime and provider surface by semantic kind, then leases it through +the explicit Verse/authority-runtime target. The Eve provider ID remains a +separate surface-owner identity. +Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain +inside the same identity-owned session. Movement, focus, target, and action +input all become `gamecult.eve.command_invocation.v1` through the +provider-advertised command boundary. ## Cut Line diff --git a/packages/org.gamecult.eve.surface/Directory.Build.props b/packages/org.gamecult.eve.surface/Directory.Build.props deleted file mode 100644 index 994fcb6..0000000 --- a/packages/org.gamecult.eve.surface/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - $(MSBuildThisFileDirectory)bin~\ - $(MSBuildThisFileDirectory)obj~\ - $(MSBuildThisFileDirectory)obj~\ - - diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj deleted file mode 100644 index f07f7e8..0000000 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - netstandard2.1 - enable - latest - enable - Portable Eve surface and provider contracts. - $(MSBuildThisFileDirectory)..\..\..\CultLib - - - - - - - diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta deleted file mode 100644 index 751910b..0000000 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ce2d4913d64690c44ae68935014fd79d -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md deleted file mode 100644 index eb65c17..0000000 --- a/packages/org.gamecult.eve.surface/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Eve Surface Contract - -This package defines the retained CultUI surface document that every Eve -runtime lowers. - -Components own normal child layout through `Children`, live CultMesh value -bindings through `StateBindings`, and nested synced document regions through -`EmbeddedDocuments`. An embedded document slot names the child document id, -schema, presentation kind, and route hint; renderers resolve that child through -CultMesh instead of rebuilding it with runtime-local projection code. - -See `../../docs/surface-contract-v1.md` for the full contract and -`../../web/fixtures/cultui-embedded-surface.json` for the parity fixture. -Runtime discovery and required feature coverage live in -`../../tools/parity/parity-manifest.json`; active GUI runtimes must list -`embeddedDocuments` as a supported feature and require the `embedded-surface` -fixture. - -Verification entrypoints: - -- Browser/TypeScript: `node --test web\eve-dsl.test.mjs` -- Semantic runtime matrix: `powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1` -- Flutter GUI contract, when Flutter is installed: `flutter test --plain-name embedded_surface_fixture_contract` -- Rust/CultMesh document sync: run `cargo test rust_preserves_cultui_embedded_surface_slots_through_typed_document_sync` from `E:/Projects/CultLib/packages/cultnet-rs` - -The parity manifest is the discovery source for web, Flutter desktop/Linux/Android, -iOS/UIKit, Android/Kotlin, Unity UI Toolkit, and Rust contract coverage. Do not -add a new renderer claim without adding its nested-surface fixture requirement -there. diff --git a/packages/org.gamecult.eve.surface/README.md.meta b/packages/org.gamecult.eve.surface/README.md.meta deleted file mode 100644 index 4bc751a..0000000 --- a/packages/org.gamecult.eve.surface/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: fe5aa27636bead047b2aecb14fdc59f2 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/Runtime.meta b/packages/org.gamecult.eve.surface/Runtime.meta deleted file mode 100644 index f150917..0000000 --- a/packages/org.gamecult.eve.surface/Runtime.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 2164f6fea1216214d895e7dc99c46ecf -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs deleted file mode 100644 index eba3b44..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.asset_catalog", SchemaId)] - [MessagePackObject] - public sealed class EveAssetCatalogDocument - { - public const string SchemaId = "gamecult.eve.asset_catalog.v1"; - - [SerializationConstructor] - public EveAssetCatalogDocument( - string schema, - string providerId, - string catalogId, - long version, - string updatedAtUtc, - EveAssetCatalogEntry[] assets) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - CatalogId = catalogId ?? ""; - Version = version; - UpdatedAtUtc = updatedAtUtc ?? ""; - Assets = assets ?? Array.Empty(); - } - - public EveAssetCatalogDocument( - string providerId, - string catalogId, - long version, - string updatedAtUtc, - EveAssetCatalogEntry[] assets) - : this(SchemaId, providerId, catalogId, version, updatedAtUtc, assets) - { - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ProviderId { get; } - [Key(2)] public string CatalogId { get; } - [Key(3)] public long Version { get; } - [Key(4)] public string UpdatedAtUtc { get; } - [Key(5)] public EveAssetCatalogEntry[] Assets { get; } - } - - [MessagePackObject] - public sealed class EveAssetCatalogEntry - { - [SerializationConstructor] - public EveAssetCatalogEntry( - string assetRef, - string assetKind, - EveAssetVariant[] variants, - Dictionary metadata) - { - AssetRef = assetRef ?? ""; - AssetKind = assetKind ?? ""; - Variants = variants ?? Array.Empty(); - Metadata = metadata == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(metadata, StringComparer.Ordinal); - } - - public EveAssetCatalogEntry( - string assetRef, - string assetKind, - EveAssetVariant[] variants, - IReadOnlyDictionary? metadata = null) - : this( - assetRef, - assetKind, - variants, - metadata == null ? new Dictionary() : new Dictionary(metadata)) - { - } - - [Key(0)] public string AssetRef { get; } - [Key(1)] public string AssetKind { get; } - [Key(2)] public EveAssetVariant[] Variants { get; } - [Key(3)] public Dictionary Metadata { get; } - } - - [MessagePackObject] - public sealed class EveAssetVariant - { - [SerializationConstructor] - public EveAssetVariant( - string runtimeId, - string platform, - string format, - string uri, - string contentHash, - long sizeBytes, - string assetKey, - Dictionary metadata) - { - RuntimeId = runtimeId ?? ""; - Platform = platform ?? ""; - Format = format ?? ""; - Uri = uri ?? ""; - ContentHash = contentHash ?? ""; - SizeBytes = sizeBytes; - AssetKey = assetKey ?? ""; - Metadata = metadata == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(metadata, StringComparer.Ordinal); - } - - public EveAssetVariant( - string runtimeId, - string platform, - string format, - string uri, - string contentHash, - long sizeBytes, - string assetKey, - IReadOnlyDictionary? metadata = null) - : this( - runtimeId, - platform, - format, - uri, - contentHash, - sizeBytes, - assetKey, - metadata == null ? new Dictionary() : new Dictionary(metadata)) - { - } - - [Key(0)] public string RuntimeId { get; } - [Key(1)] public string Platform { get; } - [Key(2)] public string Format { get; } - [Key(3)] public string Uri { get; } - [Key(4)] public string ContentHash { get; } - [Key(5)] public long SizeBytes { get; } - [Key(6)] public string AssetKey { get; } - [Key(7)] public Dictionary Metadata { get; } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta deleted file mode 100644 index 0af6487..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8f53654f7c98d894588127ba001f11b4 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs deleted file mode 100644 index d5148f1..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using GameCult.Caching; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.entity_soa_view", SchemaId)] - [MessagePackObject] - public sealed class EveEntitySoaViewDocument - { - public const string SchemaId = "gamecult.eve.entity_soa_view.v2"; - - [Key(0)] public string Schema { get; set; } = SchemaId; - [Key(1)] public string ProviderId { get; set; } = ""; - [Key(2)] public string ViewId { get; set; } = ""; - [Key(3)] public string PublishedAtUtc { get; set; } = ""; - [Key(4)] public string BodySchemaId { get; set; } = ""; - [Key(5)] public int LayoutVersion { get; set; } - [Key(6)] public long ProducerEpoch { get; set; } - [Key(7)] public long Sequence { get; set; } - [Key(8)] public int Capacity { get; set; } - [Key(9)] public EveEntitySoaBuffer[] Buffers { get; set; } = Array.Empty(); - [Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty(); - [Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty(); - [Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty(); - [Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty(); - [Key(14)] public long FrameId { get; set; } - } - - [MessagePackObject] - public sealed class EveEntitySoaBuffer - { - [Key(0)] public string BufferId { get; set; } = ""; - [Key(1)] public long ByteOffset { get; set; } - [Key(2)] public long ByteLength { get; set; } - } - - [MessagePackObject] - public sealed class EveEntitySoaColumn - { - [Key(0)] public string ColumnId { get; set; } = ""; - [Key(1)] public string Semantic { get; set; } = ""; - [Key(2)] public string BufferId { get; set; } = ""; - [Key(3)] public string ScalarType { get; set; } = ""; - [Key(4)] public long ByteOffset { get; set; } - [Key(5)] public int ElementStride { get; set; } - [Key(6)] public int ElementCount { get; set; } - [Key(7)] public string Unit { get; set; } = ""; - [Key(8)] public string CoordinateSpace { get; set; } = ""; - } - - [MessagePackObject] - public sealed class EveEntitySoaDirtyRange - { - [Key(0)] public string ColumnId { get; set; } = ""; - [Key(1)] public int StartIndex { get; set; } - [Key(2)] public int Count { get; set; } - [Key(3)] public long Sequence { get; set; } - } - - [MessagePackObject] - public sealed class EveEntityIdentity - { - [Key(0)] public int Index { get; set; } - [Key(1)] public string EntityId { get; set; } = ""; - [Key(2)] public string EntityKind { get; set; } = ""; - [Key(3)] public string Label { get; set; } = ""; - [Key(4)] public string Faction { get; set; } = ""; - [Key(5)] public bool Selectable { get; set; } - [Key(6)] public bool Controllable { get; set; } - [Key(7)] public string AssetRef { get; set; } = ""; - } - - [MessagePackObject] - public sealed class EveEntityRenderGroup - { - [Key(0)] public int GroupId { get; set; } - [Key(1)] public string MeshAssetRef { get; set; } = ""; - [Key(2)] public string MaterialAssetRef { get; set; } = ""; - [Key(3)] public int SubMeshIndex { get; set; } - [Key(4)] public int Layer { get; set; } - [Key(5)] public int InstanceCount { get; set; } - [Key(6)] public float DefaultScale { get; set; } = 1f; - [Key(7)] public float BoundsCenterX { get; set; } - [Key(8)] public float BoundsCenterY { get; set; } - [Key(9)] public float BoundsCenterZ { get; set; } - [Key(10)] public float BoundsSizeX { get; set; } - [Key(11)] public float BoundsSizeY { get; set; } - [Key(12)] public float BoundsSizeZ { get; set; } - [Key(13)] public string ShadowMode { get; set; } = "on"; - [Key(14)] public bool ReceiveShadows { get; set; } = true; - [Key(15)] public int Lod { get; set; } = -1; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta deleted file mode 100644 index b2882e9..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8e87e1be783846508efea45787f2d45c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs deleted file mode 100644 index ed8b486..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.input_capability", SchemaId)] - [MessagePackObject] - public sealed class EveInputCapabilityDocument - { - public const string SchemaId = "gamecult.eve.input_capability.v1"; - [Key(0)] public string Schema { get; set; } = SchemaId; - [Key(1)] public string ProviderId { get; set; } = ""; - [Key(2)] public string CapabilityId { get; set; } = ""; - [Key(3)] public long Version { get; set; } - [Key(4)] public EveInputActionDocument[] Actions { get; set; } = Array.Empty(); - [Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty(); - } - - [MessagePackObject] - public sealed class EveInputActionDocument - { - [Key(0)] public string ActionId { get; set; } = ""; - [Key(1)] public string Label { get; set; } = ""; - [Key(2)] public string Operation { get; set; } = ""; - [Key(3)] public string Context { get; set; } = "pilot"; - [Key(4)] public string Category { get; set; } = "ship"; - [Key(5)] public string Availability { get; set; } = "available"; - [Key(6)] public string SourceRef { get; set; } = ""; - [Key(7)] public Dictionary Payload { get; set; } = new Dictionary(StringComparer.Ordinal); - [Key(8)] public EveInputValueDocument? InputValue { get; set; } - [Key(9)] public string IconRef { get; set; } = ""; - [Key(10)] public bool ActionBar { get; set; } - } - - [MessagePackObject] - public sealed class EveInputValueDocument - { - [Key(0)] public string Model { get; set; } = ""; - [Key(1)] public string PayloadKey { get; set; } = ""; - [Key(2)] public string[] PayloadKeys { get; set; } = Array.Empty(); - [Key(3)] public double? CurrentValue { get; set; } - [Key(4)] public double? MinimumValue { get; set; } - [Key(5)] public double? MaximumValue { get; set; } - [Key(6)] public double? StepValue { get; set; } - [Key(7)] public string Unit { get; set; } = ""; - } - - [MessagePackObject] - public sealed class EveInputProfileDocument - { - [Key(0)] public string ProfileId { get; set; } = ""; - [Key(1)] public string DeviceClass { get; set; } = ""; - [Key(2)] public EveInputBindingDocument[] Bindings { get; set; } = Array.Empty(); - } - - [MessagePackObject] - public sealed class EveInputBindingDocument - { - [Key(0)] public string BindingId { get; set; } = ""; - [Key(1)] public string ActionId { get; set; } = ""; - [Key(2)] public EveInputGestureDocument Gesture { get; set; } = new EveInputGestureDocument(); - [Key(3)] public bool ActionBar { get; set; } - } - - [MessagePackObject] - public sealed class EveInputGestureDocument - { - [Key(0)] public string Kind { get; set; } = "direct"; - [Key(1)] public string[] Controls { get; set; } = Array.Empty(); - [Key(2)] public int MaxStepIntervalMs { get; set; } = 650; - [Key(3)] public string CompletionControl { get; set; } = ""; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta deleted file mode 100644 index f13f766..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4c297c7dbb64a0f49a7f805e770c5e55 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs deleted file mode 100644 index 35425a3..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace GameCult.Eve.Surface -{ - public sealed class EveInputDeviceLayout - { - public string LayoutId { get; set; } = ""; - public string DeviceClass { get; set; } = ""; - public IReadOnlyList Controls { get; set; } = Array.Empty(); - public bool SupportsDirectionalStrings { get; set; } - - public static EveInputDeviceLayout AnsiKeyboardMouse() => new EveInputDeviceLayout - { - LayoutId = "keyboard.ansi-104+mouse.standard", - DeviceClass = "keyboard-mouse", - Controls = new[] - { - Key("keyboard.escape", "Esc", 0, 0, 1.25f), Key("keyboard.f1", "F1", 2, 0), Key("keyboard.f2", "F2", 3, 0), - Key("keyboard.1", "1", 1, 1), Key("keyboard.2", "2", 2, 1), Key("keyboard.3", "3", 3, 1), Key("keyboard.4", "4", 4, 1), Key("keyboard.5", "5", 5, 1), - Key("keyboard.tab", "Tab", 0, 2, 1.5f), Key("keyboard.q", "Q", 1.5f, 2), Key("keyboard.w", "W", 2.5f, 2), Key("keyboard.e", "E", 3.5f, 2), Key("keyboard.r", "R", 4.5f, 2), - Key("keyboard.a", "A", 1.75f, 3), Key("keyboard.s", "S", 2.75f, 3), Key("keyboard.d", "D", 3.75f, 3), Key("keyboard.f", "F", 4.75f, 3), - Key("keyboard.leftShift", "Shift", 0, 4, 2.25f), Key("keyboard.space", "Space", 3.75f, 5, 6.25f), - Key("mouse.primary", "Mouse 1", 18, 1), Key("mouse.secondary", "Mouse 2", 19, 1), Key("mouse.middle", "Mouse 3", 18.5f, 2) - } - }; - - public static EveInputDeviceLayout StandardGamepad() => new EveInputDeviceLayout - { - LayoutId = "gamepad.standard", - DeviceClass = "gamepad", - SupportsDirectionalStrings = true, - Controls = new[] - { - Key("gamepad.leftStick", "Left Stick", 2, 3, 2), Key("gamepad.rightStick", "Right Stick", 8, 4, 2), - Key("gamepad.dpad.up", "Up", 1, 1), Key("gamepad.dpad.left", "Left", 0, 2), Key("gamepad.dpad.right", "Right", 2, 2), Key("gamepad.dpad.down", "Down", 1, 3), - Key("gamepad.buttonSouth", "A / Cross", 10, 3), Key("gamepad.buttonEast", "B / Circle", 11, 2), Key("gamepad.buttonWest", "X / Square", 9, 2), Key("gamepad.buttonNorth", "Y / Triangle", 10, 1), - Key("gamepad.leftTrigger", "LT / L2", 2, 0, 2), Key("gamepad.rightTrigger", "RT / R2", 9, 0, 2) - } - }; - - private static EveInputLayoutControl Key(string id, string label, float x, float y, float width = 1) => - new EveInputLayoutControl { Control = id, Label = label, X = x, Y = y, Width = width, Height = 1 }; - } - - public sealed class EveInputLayoutControl - { - public string Control { get; set; } = ""; - public string Label { get; set; } = ""; - public float X { get; set; } - public float Y { get; set; } - public float Width { get; set; } = 1; - public float Height { get; set; } = 1; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta deleted file mode 100644 index 0d7a9a2..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8a2a1258551e42a9831a3e4f9483d928 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs deleted file mode 100644 index a318066..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace GameCult.Eve.Surface -{ - public sealed class EveInputGesture - { - public string Kind { get; set; } = "direct"; - public IReadOnlyList Controls { get; set; } = Array.Empty(); - public int MaxStepIntervalMs { get; set; } = 650; - public string CompletionControl { get; set; } = ""; - } - - public sealed class EveInputBinding - { - public string BindingId { get; set; } = ""; - public string ActionId { get; set; } = ""; - public EveInputGesture Gesture { get; set; } = new EveInputGesture(); - public bool ActionBar { get; set; } - } - - public sealed class EveInputGestureResolver - { - private readonly IReadOnlyList _bindings; - private readonly HashSet _pressed = new HashSet(StringComparer.Ordinal); - private readonly Dictionary _sequences = new Dictionary(StringComparer.Ordinal); - - public EveInputGestureResolver(IEnumerable bindings) - { - _bindings = (bindings ?? Array.Empty()).Where(binding => binding != null).ToArray(); - } - - public IReadOnlyList Press(string control, long timestampMs) - { - _pressed.Add(control ?? ""); - var actions = new HashSet(StringComparer.Ordinal); - foreach (var binding in _bindings) - { - var gesture = binding.Gesture ?? new EveInputGesture(); - if (gesture.Kind == "direct" && gesture.Controls.FirstOrDefault() == control) - actions.Add(binding.ActionId); - else if (gesture.Kind == "chord" && gesture.Controls.Count > 0 && gesture.Controls.All(_pressed.Contains)) - actions.Add(binding.ActionId); - else if (gesture.Kind == "sequence" && Advance(binding, control ?? "", timestampMs)) - actions.Add(binding.ActionId); - } - return actions.ToArray(); - } - - public void Release(string control) => _pressed.Remove(control ?? ""); - - public void Reset() - { - _pressed.Clear(); - _sequences.Clear(); - } - - private bool Advance(EveInputBinding binding, string control, long timestampMs) - { - var controls = binding.Gesture.Controls; - if (controls.Count == 0) return false; - _sequences.TryGetValue(binding.BindingId, out var previous); - var index = previous.Index > 0 && timestampMs - previous.TimestampMs <= Math.Max(50, binding.Gesture.MaxStepIntervalMs) ? previous.Index : 0; - var next = controls[index] == control ? index + 1 : controls[0] == control ? 1 : 0; - if (next == controls.Count) - { - _sequences.Remove(binding.BindingId); - return string.IsNullOrWhiteSpace(binding.Gesture.CompletionControl); - } - _sequences[binding.BindingId] = new SequenceState(next, timestampMs); - return false; - } - - private readonly struct SequenceState - { - public SequenceState(int index, long timestampMs) { Index = index; TimestampMs = timestampMs; } - public int Index { get; } - public long TimestampMs { get; } - } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta deleted file mode 100644 index 3abaaab..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e3c2941be243a754092ac2543d1e6bf7 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs deleted file mode 100644 index c8c53e9..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - public static class EveInventoryInteraction - { - public const string GridKind = "inventory.grid"; - public const string ItemKind = "inventory.item"; - public const string DragSessionKind = "inventory.drag_session"; - - public static bool TryCreateDropRequest( - EveSurfaceDocument document, - EveSurfaceComponent source, - EveSurfaceComponent target, - int destinationX, - int destinationY, - string clientId, - out EveSurfaceCommandRequest? request) - { - request = null; - if (document == null || source == null || target == null || - !string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) || - !string.Equals(target.Kind, GridKind, StringComparison.Ordinal)) - { - return false; - } - - var sourceKind = source.GetProp("sourceKind", source.GetProp("source")); - var command = target.GetProp($"dropCommand.{sourceKind}", target.GetProp("dropCommand")); - if (string.IsNullOrWhiteSpace(sourceKind) || string.IsNullOrWhiteSpace(command)) - return false; - - var sourceIndex = Int(source.GetProp("sourceIndex"), -1); - var targetIndex = Int(target.GetProp("targetIndex"), -1); - var targetKind = target.GetProp("targetKind"); - var payload = new Dictionary(StringComparer.Ordinal) - { - ["sourceKind"] = sourceKind, - ["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")), - ["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture), - ["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1) - .ToString(CultureInfo.InvariantCulture), - ["itemKey"] = source.GetProp("itemKey"), - ["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture), - ["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture), - ["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture), - ["destinationKind"] = targetKind, - ["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")), - ["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture), - ["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1) - .ToString(CultureInfo.InvariantCulture), - ["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture), - ["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture), - ["hasDestinationPosition"] = "true" - }; - var template = document.Commands.FirstOrDefault(candidate => - string.Equals(candidate.Command, command, StringComparison.Ordinal)); - var operation = template == null - ? CultMesh.OperationInvocation(command) - : CultMesh.OperationInvocation(template.Operation); - request = new EveSurfaceCommandRequest( - document.ProviderId, - document.Surface.Id, - operation, - CultMesh.OperationPayload(payload), - DateTimeOffset.UtcNow, - string.IsNullOrWhiteSpace(clientId) ? "unity-uitoolkit" : clientId); - return true; - } - - private static int Int(string value, int fallback) => - int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) - ? parsed - : fallback; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta deleted file mode 100644 index 57cb330..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 17fd4173e6e44b4b9c503d343021ffcb diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs deleted file mode 100644 index a52d436..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ /dev/null @@ -1,414 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; -using MessagePack.Formatters; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.provider_advertisement", SchemaId)] - [MessagePackObject] - [MessagePackFormatter(typeof(EveProviderAdvertisementCompatibilityFormatter))] - public sealed class EveProviderAdvertisementDocument - { - public const string SchemaId = "gamecult.eve.provider_advertisement.v1"; - - public EveProviderAdvertisementDocument( - string providerId, - string serviceId, - string verseId, - string title, - string kind, - string cultMeshAddress, - string updatedAtUtc, - EveProviderFreshness freshness, - IReadOnlyList schemas, - IReadOnlyList witnesses, - IReadOnlyList surfaces, - IReadOnlyList commands, - IReadOnlyList? authorizedBodyProducerIds = null) - : this(SchemaId, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands, authorizedBodyProducerIds) - { - } - - [SerializationConstructor] - public EveProviderAdvertisementDocument( - string schema, - string providerId, - string serviceId, - string verseId, - string title, - string kind, - string cultMeshAddress, - string updatedAtUtc, - EveProviderFreshness freshness, - IReadOnlyList schemas, - IReadOnlyList witnesses, - IReadOnlyList surfaces, - IReadOnlyList commands, - IReadOnlyList? authorizedBodyProducerIds = null) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - ServiceId = serviceId ?? ""; - VerseId = verseId ?? ""; - Title = title ?? ""; - Kind = kind ?? ""; - CultMeshAddress = cultMeshAddress ?? ""; - UpdatedAtUtc = updatedAtUtc ?? ""; - Freshness = freshness ?? new EveProviderFreshness("unknown", "", 0); - Schemas = schemas ?? Array.Empty(); - Witnesses = witnesses ?? Array.Empty(); - Surfaces = surfaces ?? Array.Empty(); - Commands = commands ?? Array.Empty(); - AuthorizedBodyProducerIds = authorizedBodyProducerIds ?? Array.Empty(); - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ProviderId { get; } - [Key(2)] public string ServiceId { get; } - [Key(3)] public string VerseId { get; } - [Key(4)] public string Title { get; } - [Key(5)] public string Kind { get; } - [Key(6)] public string CultMeshAddress { get; } - [Key(7)] public string UpdatedAtUtc { get; } - [Key(8)] public EveProviderFreshness Freshness { get; } - [Key(9)] public IReadOnlyList Schemas { get; } - [Key(10)] public IReadOnlyList Witnesses { get; } - [Key(11)] public IReadOnlyList Surfaces { get; } - [Key(12)] public IReadOnlyList Commands { get; } - [Key(13)] public IReadOnlyList AuthorizedBodyProducerIds { get; } - } - - /// - /// Reads canonical provider advertisements and the original Aetheria-expanded v1 layout. - /// - public sealed class EveProviderAdvertisementCompatibilityFormatter : - IMessagePackFormatter - { - private const int CurrentFieldCount = 14; - private const int LegacyFieldCount = 16; - - public void Serialize( - ref MessagePackWriter writer, - EveProviderAdvertisementDocument? value, - MessagePackSerializerOptions options) - { - if (value == null) { writer.WriteNil(); return; } - writer.WriteArrayHeader(CurrentFieldCount); - writer.Write(value.Schema); - writer.Write(value.ProviderId); - writer.Write(value.ServiceId); - writer.Write(value.VerseId); - writer.Write(value.Title); - writer.Write(value.Kind); - writer.Write(value.CultMeshAddress); - writer.Write(value.UpdatedAtUtc); - Formatter(options).Serialize(ref writer, value.Freshness, options); - Formatter>(options).Serialize(ref writer, value.Schemas, options); - Formatter>(options).Serialize(ref writer, value.Witnesses, options); - Formatter>(options).Serialize(ref writer, value.Surfaces, options); - Formatter>(options).Serialize(ref writer, value.Commands, options); - Formatter>(options).Serialize(ref writer, value.AuthorizedBodyProducerIds, options); - } - - public EveProviderAdvertisementDocument? Deserialize( - ref MessagePackReader reader, - MessagePackSerializerOptions options) - { - if (reader.TryReadNil()) return null; - if (reader.NextMessagePackType != MessagePackType.Array) - throw new MessagePackSerializationException("Eve provider advertisement must be an array."); - - options.Security.DepthStep(ref reader); - try - { - var fields = reader.ReadArrayHeader(); - if (fields == LegacyFieldCount) - return ReadLegacy(ref reader, options); - - var schema = ReadString(ref reader, fields, 0); - var providerId = ReadString(ref reader, fields, 1); - var serviceId = ReadString(ref reader, fields, 2); - var verseId = ReadString(ref reader, fields, 3); - var title = ReadString(ref reader, fields, 4); - var kind = ReadString(ref reader, fields, 5); - var address = ReadString(ref reader, fields, 6); - var updatedAtUtc = ReadString(ref reader, fields, 7); - var freshness = fields > 8 ? Formatter(options).Deserialize(ref reader, options) : null; - var schemas = fields > 9 ? Formatter>(options).Deserialize(ref reader, options) : null; - var witnesses = fields > 10 ? Formatter>(options).Deserialize(ref reader, options) : null; - var surfaces = fields > 11 ? Formatter>(options).Deserialize(ref reader, options) : null; - var commands = fields > 12 ? Formatter>(options).Deserialize(ref reader, options) : null; - var authorizedBodyProducerIds = fields > 13 ? Formatter>(options).Deserialize(ref reader, options) : null; - for (var index = CurrentFieldCount; index < fields; index++) reader.Skip(); - return new EveProviderAdvertisementDocument( - schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc, - freshness ?? new EveProviderFreshness("unknown", "", 0), - schemas ?? Array.Empty(), - witnesses ?? Array.Empty(), - surfaces ?? Array.Empty(), - commands ?? Array.Empty(), - authorizedBodyProducerIds ?? Array.Empty()); - } - finally { reader.Depth--; } - } - - private static EveProviderAdvertisementDocument ReadLegacy( - ref MessagePackReader reader, - MessagePackSerializerOptions options) - { - var schema = ReadString(ref reader); - var providerId = ReadString(ref reader); - var serviceId = ReadString(ref reader); - var verseId = ReadString(ref reader); - reader.Skip(); // RootVerse remains provider-owned settings, not portable advertisement state. - reader.Skip(); // CanonicalService - reader.Skip(); // LocatedService - var address = ReadString(ref reader); - var title = ReadString(ref reader); - var kind = ReadString(ref reader); - var updatedAtUtc = ReadString(ref reader); - var freshness = Formatter(options).Deserialize(ref reader, options) - ?? new EveProviderFreshness("unknown", "", 0); - var schemas = Formatter>(options).Deserialize(ref reader, options) - ?? Array.Empty(); - var witnesses = Formatter>(options).Deserialize(ref reader, options) - ?? Array.Empty(); - var surfaces = ReadLegacySurfaces(ref reader); - var commands = ReadLegacyCommands(ref reader); - return new EveProviderAdvertisementDocument( - schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc, - freshness, schemas, witnesses, surfaces, commands); - } - - private static EveAdvertisedSurface[] ReadLegacySurfaces(ref MessagePackReader reader) - { - var count = reader.ReadArrayHeader(); - var values = new EveAdvertisedSurface[count]; - for (var index = 0; index < count; index++) - { - var fields = reader.ReadArrayHeader(); - var schema = ReadString(ref reader, fields, 0); - var surfaceId = ReadString(ref reader, fields, 1); - var recordRef = ReadString(ref reader, fields, 2); - var transport = ReadString(ref reader, fields, 3); - var status = ReadString(ref reader, fields, 4); - for (var field = 5; field < fields; field++) reader.Skip(); - values[index] = new EveAdvertisedSurface( - surfaceId, schema, recordRef, transport, status, "", null); - } - return values; - } - - private static EveAdvertisedCommand[] ReadLegacyCommands(ref MessagePackReader reader) - { - var count = reader.ReadArrayHeader(); - var values = new EveAdvertisedCommand[count]; - for (var index = 0; index < count; index++) - { - var fields = reader.ReadArrayHeader(); - var command = ReadString(ref reader, fields, 0); - var transport = ReadString(ref reader, fields, 1); - var summary = ReadString(ref reader, fields, 2); - for (var field = 3; field < fields; field++) reader.Skip(); - values[index] = new EveAdvertisedCommand(command, "", transport, summary); - } - return values; - } - - private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => - options.Resolver.GetFormatter() - ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); - - private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; - private static string ReadString(ref MessagePackReader reader, int fields, int index) => - index < fields ? ReadString(ref reader) : ""; - } - - - [MessagePackObject] - public sealed class EveProviderFreshness - { - [SerializationConstructor] - public EveProviderFreshness(string state, string lastSeenAtUtc, long maxAgeMs) - { - State = state ?? ""; - LastSeenAtUtc = lastSeenAtUtc ?? ""; - MaxAgeMs = maxAgeMs; - } - - [Key(0)] public string State { get; } - [Key(1)] public string LastSeenAtUtc { get; } - [Key(2)] public long MaxAgeMs { get; } - } - - [MessagePackObject] - public sealed class EveProviderWitness - { - [SerializationConstructor] - public EveProviderWitness(string kind, string reference, string summary) - { - Kind = kind ?? ""; - Reference = reference ?? ""; - Summary = summary ?? ""; - } - - [Key(0)] public string Kind { get; } - [Key(1)] public string Reference { get; } - [Key(2)] public string Summary { get; } - } - - [MessagePackObject] - public sealed class EveAdvertisedSurface - { - [SerializationConstructor] - public EveAdvertisedSurface( - string surfaceId, - string schema, - string recordRef, - string transport, - string status, - string surfaceKind, - EveWorldInteractionAdvertisement? worldInteraction = null) - { - SurfaceId = surfaceId ?? ""; - Schema = schema ?? ""; - RecordRef = recordRef ?? ""; - Transport = transport ?? ""; - Status = status ?? ""; - SurfaceKind = surfaceKind ?? ""; - WorldInteraction = worldInteraction; - } - - [Key(0)] public string SurfaceId { get; } - [Key(1)] public string Schema { get; } - [Key(2)] public string RecordRef { get; } - [Key(3)] public string Transport { get; } - [Key(4)] public string Status { get; } - [Key(5)] public string SurfaceKind { get; } - [Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; } - } - - [MessagePackObject] - public sealed class EveWorldInteractionAdvertisement - { - [SerializationConstructor] - public EveWorldInteractionAdvertisement( - string projectionKind, - IReadOnlyList stateSchemas, - string commandBoundary, - string commandRecordRef, - string receiptSchema, - string receiptRecordRef, - string assetManifestRecordRef, - IReadOnlyList loweringTargets, - string ownership) - { - ProjectionKind = projectionKind ?? ""; - StateSchemas = stateSchemas ?? Array.Empty(); - CommandBoundary = commandBoundary ?? ""; - CommandRecordRef = commandRecordRef ?? ""; - ReceiptSchema = receiptSchema ?? ""; - ReceiptRecordRef = receiptRecordRef ?? ""; - AssetManifestRecordRef = assetManifestRecordRef ?? ""; - LoweringTargets = loweringTargets ?? Array.Empty(); - Ownership = ownership ?? ""; - } - - [Key(0)] public string ProjectionKind { get; } - [Key(1)] public IReadOnlyList StateSchemas { get; } - [Key(2)] public string CommandBoundary { get; } - [Key(3)] public string CommandRecordRef { get; } - [Key(4)] public string ReceiptSchema { get; } - [Key(5)] public string ReceiptRecordRef { get; } - [Key(6)] public string AssetManifestRecordRef { get; } - [Key(7)] public IReadOnlyList LoweringTargets { get; } - [Key(8)] public string Ownership { get; } - } - - [MessagePackObject] - public sealed class EveAdvertisedCommand - { - [SerializationConstructor] - public EveAdvertisedCommand(string command, string surfaceId, string transport, string summary) - { - Command = command ?? ""; - SurfaceId = surfaceId ?? ""; - Transport = transport ?? ""; - Summary = summary ?? ""; - } - - [Key(0)] public string Command { get; } - [Key(1)] public string SurfaceId { get; } - [Key(2)] public string Transport { get; } - [Key(3)] public string Summary { get; } - } - - [CultDocument("gamecult.eve.command_receipt", SchemaId)] - [MessagePackObject] - public sealed class EveCommandReceiptDocument - { - public const string SchemaId = "gamecult.eve.command_receipt.v1"; - - public EveCommandReceiptDocument( - string receiptId, - string commandId, - string command, - string state, - string ownerRepo, - string authority, - string providerId, - string surfaceId, - string message, - string issuedAtUtc, - long sourceVersion) - : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion) - { - } - - [SerializationConstructor] - public EveCommandReceiptDocument( - string schema, - string receiptId, - string commandId, - string command, - string state, - string ownerRepo, - string authority, - string providerId, - string surfaceId, - string message, - string issuedAtUtc, - long sourceVersion) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ReceiptId = receiptId ?? ""; - CommandId = commandId ?? ""; - Command = command ?? ""; - State = state ?? ""; - OwnerRepo = ownerRepo ?? ""; - Authority = authority ?? ""; - ProviderId = providerId ?? ""; - SurfaceId = surfaceId ?? ""; - Message = message ?? ""; - IssuedAtUtc = issuedAtUtc ?? ""; - SourceVersion = sourceVersion; - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ReceiptId { get; } - [Key(2)] public string CommandId { get; } - [Key(3)] public string Command { get; } - [Key(4)] public string State { get; } - [Key(5)] public string OwnerRepo { get; } - [Key(6)] public string Authority { get; } - [Key(7)] public string ProviderId { get; } - [Key(8)] public string SurfaceId { get; } - [Key(9)] public string Message { get; } - [Key(10)] public string IssuedAtUtc { get; } - [Key(11)] public long SourceVersion { get; } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta deleted file mode 100644 index f29c875..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f7ea0cd07c4d5da4099b5acf8d44ca2e \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs deleted file mode 100644 index 2d88eb5..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs +++ /dev/null @@ -1,874 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - /// - /// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors. - /// - public sealed class EveSurfaceBuilder - { - private readonly string _surfaceId; - private readonly List _children = new(); - private readonly List _styles = new(); - private readonly List _commands = new(); - private EveSurfaceComponent? _root; - private long _version = 1; - private string _providerId = "gamecult"; - private string _providerKind = "cultui.surface"; - private string _title = ""; - private string _updatedAtUtc = ""; - - public EveSurfaceBuilder(string surfaceId) - { - _surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId)); - } - - public EveSurfaceBuilder Provider(string providerId, string providerKind) - { - _providerId = providerId ?? ""; - _providerKind = providerKind ?? ""; - return this; - } - - public EveSurfaceBuilder Title(string title) - { - _title = title ?? ""; - _children.Add(Text($"{_surfaceId}.title", _title, "text.title")); - return this; - } - - public EveSurfaceBuilder TitleSubtitle(string title, string subtitle) - { - _title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}"; - _children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title")); - _children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle")); - return this; - } - - public EveSurfaceBuilder Text(string text, string? id = null) - { - _children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text")); - return this; - } - - public EveSurfaceBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _commands.Add(new EveCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _commands.Add(new EveCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder ButtonColumn(string id, Action build) - { - return Group(id, "column", build); - } - - public EveSurfaceBuilder ButtonRow(string id, Action build) - { - return Group(id, "row", build); - } - - public EveSurfaceBuilder Form(string id, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var form = new EveSurfaceFormBuilder(id, AddCommand); - build(form); - _children.Add(form.Build()); - return this; - } - - public EveSurfaceBuilder RootColumn(string id, Action build) - { - _root = BuildContainer(id, "partition", Props(("split", "y")), build); - return this; - } - - public EveSurfaceBuilder RootRow(string id, Action build) - { - _root = BuildContainer(id, "partition", Props(("split", "x")), build); - return this; - } - - public EveSurfaceBuilder RootGrid( - string id, - int columns, - int rows, - Action build) - { - _root = BuildContainer( - id, - "grid", - Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build); - return this; - } - - public EveSurfaceBuilder Row(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", Props(("split", "x")), build)); - return this; - } - - public EveSurfaceBuilder Column(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", Props(("split", "y")), build)); - return this; - } - - public EveSurfaceBuilder Grid(string id, int columns, int rows, Action build) - { - _children.Add(BuildContainer( - id, - "grid", - Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build)); - return this; - } - - public EveSurfaceBuilder Modal(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "modal", Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceBuilder EmbeddedDocument( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - { - _children.Add(new EveSurfaceComponent( - $"{_surfaceId}.slot.{Slug(slotId)}", - "surface.slot", - new Dictionary(StringComparer.Ordinal) - { - ["slotId"] = slotId ?? "", - ["documentId"] = documentId ?? "", - ["schemaId"] = schemaId ?? "", - ["presentationKind"] = presentationKind ?? "" - }, - Array.Empty(), - Array.Empty(), - new[] - { - new EveEmbeddedDocumentSlot( - slotId ?? "", - documentId ?? "", - schemaId ?? "", - presentationKind ?? "", - routeHint) - })); - return this; - } - - public EveSurfaceBuilder Style(string name, string value) - { - _styles.Add(new EveStyleToken(name, value)); - return this; - } - - public EveSurfaceBuilder Version(long version) - { - _version = version; - return this; - } - - public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc) - { - _updatedAtUtc = updatedAtUtc ?? ""; - return this; - } - - public EveSurfaceDocument Build() - { - return new EveSurfaceDocument( - _providerId, - _providerKind, - _title, - _version, - string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc, - new EveSurfaceTree( - _surfaceId, - _root ?? new EveSurfaceComponent( - $"{_surfaceId}.root", - "surface", - EmptyProps(), - _children.ToArray()), - _styles.ToArray()), - _commands.ToArray()); - } - - private EveSurfaceBuilder Group(string id, string kind, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var group = new EveSurfaceGroupBuilder(id, kind, AddCommand); - build(group); - _children.Add(group.Build()); - return this; - } - - private EveSurfaceComponent BuildContainer( - string id, - string kind, - IReadOnlyDictionary props, - Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var container = new EveSurfaceContainerBuilder(id, kind, props, AddCommand); - build(container); - return container.Build(); - } - - private void AddCommand(EveCommandTemplate command) - { - if (command != null) - _commands.Add(command); - } - - internal static EveSurfaceComponent Text(string id, string value, string kind) - { - return new EveSurfaceComponent( - id, - kind, - new Dictionary(StringComparer.Ordinal) - { - ["value"] = value ?? "" - }, - Array.Empty()); - } - - internal static EveSurfaceComponent ButtonComponent( - string id, - string label, - CultMeshOperationBindingDescriptor operation) - { - return new EveSurfaceComponent( - id, - "control.button", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? operation.Label, - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty()); - } - - internal static Dictionary EmptyProps() - { - return new Dictionary(StringComparer.Ordinal); - } - - internal static Dictionary Props(params (string Key, string Value)[] values) - { - return (values ?? Array.Empty<(string Key, string Value)>()) - .ToDictionary(value => value.Key, value => value.Value ?? "", StringComparer.Ordinal); - } - - internal static string Slug(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return "unnamed"; - - var chars = value - .Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.') - .ToArray(); - var slug = new string(chars).Trim('.'); - while (slug.Contains("..", StringComparison.Ordinal)) - slug = slug.Replace("..", ".", StringComparison.Ordinal); - return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug; - } - - private static string RequireNonEmpty(string value, string paramName) - { - return string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Value must be non-empty.", paramName) - : value; - } - } - - public sealed class EveSurfaceContainerBuilder - { - private readonly string _id; - private readonly string _kind; - private readonly Dictionary _props; - private readonly Action _addCommand; - private readonly List _children = new(); - private readonly List _stateBindings = new(); - private readonly List _embeddedDocuments = new(); - - internal EveSurfaceContainerBuilder( - string id, - string kind, - IReadOnlyDictionary props, - Action addCommand) - { - _id = id ?? ""; - _kind = kind ?? "partition"; - _props = new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal); - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceContainerBuilder Gap(int value) => Prop("gap", value); - - public EveSurfaceContainerBuilder Padding(int value) => Prop("padding", value); - - public EveSurfaceContainerBuilder Size(string value) => Prop("size", value); - - public EveSurfaceContainerBuilder Min(string value) => Prop("min", value); - - public EveSurfaceContainerBuilder Max(string value) => Prop("max", value); - - public EveSurfaceContainerBuilder Align(string value) => Prop("align", value); - - public EveSurfaceContainerBuilder Clip(string value = "hidden") => Prop("clip", value); - - public EveSurfaceContainerBuilder Scroll(string value = "y") => Prop("scroll", value); - - public EveSurfaceContainerBuilder Region(string value) => Prop("region", value); - - public EveSurfaceContainerBuilder Bind(CultMeshStateBindingDescriptor binding) - { - if (binding != null) - _stateBindings.Add(binding); - return this; - } - - public EveSurfaceContainerBuilder Row(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build)); - return this; - } - - public EveSurfaceContainerBuilder Column(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build)); - return this; - } - - public EveSurfaceContainerBuilder Grid( - string id, - int columns, - int rows, - Action build) - { - _children.Add(BuildContainer( - id, - "grid", - EveSurfaceBuilder.Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build)); - return this; - } - - public EveSurfaceContainerBuilder Pane(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder Card(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder Modal(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder FieldRow( - string id, - string label, - Action build) - { - return Row(id, row => row - .Gap(8) - .Label($"{id}.label", label, labelPart => labelPart.Size("12rem")) - .Column($"{id}.field", field => - { - field.Size("1fr"); - build(field); - })); - } - - public EveSurfaceContainerBuilder Label( - string id, - string text, - Action? configure = null) - { - return Leaf(id, "label", EveSurfaceBuilder.Props(("value", text ?? "")), null, null, configure); - } - - public EveSurfaceContainerBuilder Text(string id, string text) - { - return Leaf(id, "text", EveSurfaceBuilder.Props(("value", text ?? ""))); - } - - public EveSurfaceContainerBuilder Title(string id, string text) - { - return Leaf(id, "text.title", EveSurfaceBuilder.Props(("value", text ?? ""))); - } - - public EveSurfaceContainerBuilder Metric( - string id, - string label, - string value, - CultMeshStateBindingDescriptor? binding = null) - { - return Leaf( - id, - "metric", - EveSurfaceBuilder.Props(("label", label ?? ""), ("value", value ?? "")), - binding); - } - - public EveSurfaceContainerBuilder Progress( - string id, - string label, - string value, - string min, - string max, - CultMeshStateBindingDescriptor? binding = null) - { - return Leaf( - id, - "progress", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("value", value ?? ""), - ("min", min ?? ""), - ("max", max ?? "")), - binding); - } - - public EveSurfaceContainerBuilder Button(string id, string label, string command) - { - return Button(id, label, CultMesh.OperationBinding(command, label)); - } - - public EveSurfaceContainerBuilder Button( - string id, - string label, - CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent(id, label, operation)); - return this; - } - - public EveSurfaceContainerBuilder PopupButton( - string id, - string label, - string title, - Action build, - string anchor = "viewport", - string placement = "center", - int offsetX = 0, - int offsetY = 0) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var popup = BuildContainer( - $"{id}.popup", - "modal", - EveSurfaceBuilder.Props( - ("title", title ?? label ?? ""), - ("anchor", anchor ?? "viewport"), - ("placement", placement ?? "center"), - ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("presentation", "popover")), - build); - _children.Add(new EveSurfaceComponent( - id ?? "", - "control.popup", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("title", title ?? label ?? ""), - ("anchor", anchor ?? "viewport"), - ("placement", placement ?? "center"), - ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture))), - new[] { popup })); - return this; - } - - public EveSurfaceContainerBuilder EnumField( - string id, - string label, - string value, - CultMeshStateBindingDescriptor optionsBinding, - CultMeshOperationBindingDescriptor setValue, - string anchor = "trigger", - string placement = "right-down") - { - return FieldRow(id, label, field => field.PopupButton( - $"{id}.dropdown", - value, - label, - popup => popup.OptionList($"{id}.options", label, optionsBinding, setValue), - anchor, - placement)); - } - - public EveSurfaceContainerBuilder OptionList( - string id, - string label, - CultMeshStateBindingDescriptor itemsBinding, - CultMeshOperationBindingDescriptor operation) - { - if (itemsBinding == null) throw new ArgumentNullException(nameof(itemsBinding)); - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(new EveSurfaceComponent( - id ?? "", - "options", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("command", operation.OperationId), - ("operationId", operation.OperationId), - ("schemaId", operation.SchemaId)), - Array.Empty(), - new[] { itemsBinding })); - return this; - } - - public EveSurfaceContainerBuilder TextInput( - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.input.text", id, label, value, operation, binding); - } - - public EveSurfaceContainerBuilder Toggle( - string id, - string label, - bool value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.toggle", id, label, value ? "true" : "false", operation, binding); - } - - public EveSurfaceContainerBuilder Slider( - string id, - string label, - string value, - string min, - string max, - string step, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control( - "control.slider", - id, - label, - value, - operation, - binding, - ("min", min ?? ""), - ("max", max ?? ""), - ("step", step ?? "")); - } - - public EveSurfaceContainerBuilder Select( - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.select", id, label, value, operation, binding); - } - - public EveSurfaceContainerBuilder EmbeddedDocument( - string id, - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - { - _children.Add(new EveSurfaceComponent( - id ?? "", - "surface.slot", - EveSurfaceBuilder.Props( - ("slotId", slotId ?? ""), - ("documentId", documentId ?? ""), - ("schemaId", schemaId ?? ""), - ("presentationKind", presentationKind ?? "")), - Array.Empty(), - Array.Empty(), - new[] - { - new EveEmbeddedDocumentSlot( - slotId ?? "", - documentId ?? "", - schemaId ?? "", - presentationKind ?? "", - routeHint) - })); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent( - _id, - _kind, - _props, - _children.ToArray(), - _stateBindings.ToArray(), - _embeddedDocuments.ToArray()); - } - - private EveSurfaceContainerBuilder Prop(string key, int value) - { - _props[key] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); - return this; - } - - private EveSurfaceContainerBuilder Prop(string key, string value) - { - _props[key] = value ?? ""; - return this; - } - - private EveSurfaceContainerBuilder Leaf( - string id, - string kind, - IReadOnlyDictionary props, - CultMeshStateBindingDescriptor? binding = null, - IReadOnlyList? children = null, - Action? configure = null) - { - var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); - configure?.Invoke(builder); - var component = new EveSurfaceComponent( - id ?? "", - kind ?? "", - new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal), - children ?? Array.Empty(), - binding == null ? Array.Empty() : new[] { binding }); - if (builder._props.Count != (props?.Count ?? 0)) - { - component = new EveSurfaceComponent( - id ?? "", - kind ?? "", - new Dictionary(builder._props, StringComparer.Ordinal), - children ?? Array.Empty(), - binding == null ? Array.Empty() : new[] { binding }); - } - _children.Add(component); - return this; - } - - private EveSurfaceContainerBuilder Control( - string kind, - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding, - params (string Key, string Value)[] extraProps) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - var props = EveSurfaceBuilder.Props( - new[] - { - ("label", label ?? ""), - ("value", value ?? ""), - ("command", operation.OperationId), - ("operationId", operation.OperationId), - ("schemaId", operation.SchemaId) - }.Concat(extraProps ?? Array.Empty<(string Key, string Value)>()).ToArray()); - return Leaf(kind: kind, id: id, props: props, binding: binding); - } - - private EveSurfaceComponent BuildContainer( - string id, - string kind, - IReadOnlyDictionary props, - Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var container = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); - build(container); - return container.Build(); - } - } - - public sealed class EveSurfaceGroupBuilder - { - private readonly string _id; - private readonly string _kind; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceGroupBuilder( - string id, - string kind, - Action addCommand) - { - _id = id ?? ""; - _kind = kind ?? "column"; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceGroupBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - } - - public sealed class EveSurfaceFormBuilder - { - private readonly string _id; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceFormBuilder(string id, Action addCommand) - { - _id = id ?? ""; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceFormBuilder Text( - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(Control( - "control.text", - label, - value, - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Toggle( - string label, - bool value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(Control( - "control.toggle", - label, - value ? "true" : "false", - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Metric( - string label, - string value, - CultMeshStateBindingDescriptor? binding = null) - { - var bindings = binding == null - ? Array.Empty() - : new[] { binding }; - _children.Add(new EveSurfaceComponent( - $"{_id}.metric.{EveSurfaceBuilder.Slug(label)}", - "metric", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "" - }, - Array.Empty(), - bindings)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - - private EveSurfaceComponent Control( - string kind, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding) - { - var bindings = binding == null - ? Array.Empty() - : new[] { binding }; - return new EveSurfaceComponent( - $"{_id}.{EveSurfaceBuilder.Slug(label)}", - kind, - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "", - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty(), - bindings); - } - } - - public static class EveSurface - { - public static EveSurfaceBuilder Create(string surfaceId) - { - return new EveSurfaceBuilder(surfaceId); - } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta deleted file mode 100644 index adc41da..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 97ac7f757692aef4db64d75d0df6fe51 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs deleted file mode 100644 index 9eed51f..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs +++ /dev/null @@ -1,538 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GameCult.Caching; -using GameCult.Mesh; -using MessagePack; -using MessagePack.Formatters; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] - [MessagePackObject] - [MessagePackFormatter(typeof(EveSurfaceDocumentCompatibilityFormatter))] - public sealed class EveSurfaceDocument - { - public const string DefaultType = "surface-state"; - public const string SchemaId = "gamecult.eve.surface.v1"; - - public EveSurfaceDocument( - string providerId, - string providerKind, - string title, - long version, - string updatedAtUtc, - EveSurfaceTree surface, - IReadOnlyList commands) - : this(DefaultType, SchemaId, providerId, providerKind, title, version, updatedAtUtc, surface, commands) - { - } - - [SerializationConstructor] - public EveSurfaceDocument( - string type, - string schema, - string providerId, - string providerKind, - string title, - long version, - string updatedAtUtc, - EveSurfaceTree surface, - IReadOnlyList commands) - { - Type = string.IsNullOrWhiteSpace(type) ? DefaultType : type; - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - ProviderKind = providerKind ?? ""; - Title = title ?? ""; - Version = version; - UpdatedAtUtc = updatedAtUtc ?? ""; - Surface = surface ?? throw new ArgumentNullException(nameof(surface)); - Commands = commands ?? Array.Empty(); - } - - [Key(0)] - public string Type { get; } - - [Key(1)] - public string Schema { get; } - - [Key(2)] - public string ProviderId { get; } - - [Key(3)] - public string ProviderKind { get; } - - [Key(4)] - public string Title { get; } - - [Key(5)] - public long Version { get; } - - [Key(6)] - public string UpdatedAtUtc { get; } - - [Key(7)] - public EveSurfaceTree Surface { get; } - - [Key(8)] - public IReadOnlyList Commands { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceTree - { - [SerializationConstructor] - public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList styles) - { - Id = id ?? ""; - Root = root ?? throw new ArgumentNullException(nameof(root)); - Styles = styles ?? Array.Empty(); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public EveSurfaceComponent Root { get; } - - [Key(2)] - public IReadOnlyList Styles { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceComponent - { - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children) - : this(id, kind, props, children, StateBindingsFromProps(props)) - { - } - - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - IReadOnlyList stateBindings) - : this(id, kind, props, children, stateBindings, Array.Empty()) - { - } - - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - IReadOnlyList stateBindings, - IReadOnlyList embeddedDocuments, - IReadOnlyDictionary? layout = null, - IReadOnlyDictionary? style = null) - : this( - id, - kind, - props, - children, - (stateBindings ?? Array.Empty()) - .Select(CultMeshStateBindingRecord.FromBinding) - .ToArray(), - embeddedDocuments, - layout, - style) - { - } - - [SerializationConstructor] - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - CultMeshStateBindingRecord[] stateBindingRecords, - IReadOnlyList embeddedDocuments, - IReadOnlyDictionary? layout = null, - IReadOnlyDictionary? style = null) - { - Id = id ?? ""; - Kind = kind ?? ""; - Props = props ?? new Dictionary(StringComparer.Ordinal); - Children = children ?? Array.Empty(); - StateBindingRecords = stateBindingRecords ?? Array.Empty(); - EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); - Layout = layout ?? new Dictionary(StringComparer.Ordinal); - Style = style ?? new Dictionary(StringComparer.Ordinal); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public string Kind { get; } - - [Key(2)] - public IReadOnlyDictionary Props { get; } - - [Key(3)] - public IReadOnlyList Children { get; } - - [Key(4)] - public CultMeshStateBindingRecord[] StateBindingRecords { get; } - - [IgnoreMember] - public IReadOnlyList StateBindings => - StateBindingRecords.Select(record => record.ToBinding()).ToArray(); - - [Key(5)] - public IReadOnlyList EmbeddedDocuments { get; } - - [Key(6)] - public IReadOnlyDictionary Layout { get; } - - [Key(7)] - public IReadOnlyDictionary Style { get; } - - public string GetProp(string key, string fallback = "") - { - return Props.TryGetValue(key, out var value) ? value : fallback; - } - - private static IReadOnlyList StateBindingsFromProps( - IReadOnlyDictionary? props) - { - if (props == null || props.Count == 0) - return Array.Empty(); - - var bindings = new List(); - foreach (var prop in props) - { - if (string.IsNullOrWhiteSpace(prop.Value) || - !prop.Key.EndsWith("PointerId", StringComparison.Ordinal)) - { - continue; - } - - var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length); - if (targetProp.Length == 0) - targetProp = "value"; - bindings.Add(new CultMeshStateBindingDescriptor(targetProp, prop.Value)); - } - - return bindings; - } - } - - [MessagePackObject] - public sealed class EveEmbeddedDocumentSlot - { - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - : this(slotId, documentId, schemaId, presentationKind, CultMeshRouteRecord.FromRoute(routeHint)) - { - } - - [SerializationConstructor] - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteRecord route) - { - SlotId = slotId ?? ""; - DocumentId = documentId ?? ""; - SchemaId = schemaId ?? ""; - PresentationKind = presentationKind ?? ""; - Route = route ?? CultMeshRouteRecord.FromRoute(CultMeshRouteHint.Automatic); - } - - [Key(0)] - public string SlotId { get; } - - [Key(1)] - public string DocumentId { get; } - - [Key(2)] - public string SchemaId { get; } - - [Key(3)] - public string PresentationKind { get; } - - [Key(4)] - public CultMeshRouteRecord Route { get; } - - [IgnoreMember] - public CultMeshRouteHint RouteHint => Route.ToRoute(); - } - - [MessagePackObject] - public sealed class EveStyleToken - { - [SerializationConstructor] - public EveStyleToken(string name, string value) - { - Name = name ?? ""; - Value = value ?? ""; - } - - [Key(0)] - public string Name { get; } - - [Key(1)] - public string Value { get; } - } - - [MessagePackObject] - [MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))] - public sealed class EveCommandTemplate - { - public EveCommandTemplate(CultMeshOperationBindingDescriptor operation) - : this(CultMeshOperationBindingRecord.FromBinding(operation)) - { - } - - [SerializationConstructor] - public EveCommandTemplate(CultMeshOperationBindingRecord operationRecord) - { - OperationRecord = operationRecord ?? throw new ArgumentNullException(nameof(operationRecord)); - } - - [Key(0)] - public CultMeshOperationBindingRecord OperationRecord { get; } - - [IgnoreMember] - public CultMeshOperationBindingDescriptor Operation => OperationRecord.ToBinding(); - - [IgnoreMember] - public string Command => Operation.OperationId; - - [IgnoreMember] - public string Label => Operation.Label; - - [IgnoreMember] - public string Transport => Operation.RouteHint.Description ?? ""; - } - - [CultDocument("gamecult.eve.command_invocation", SchemaId)] - [MessagePackObject] - public sealed class EveSurfaceCommandRequest - { - public const string SchemaId = "gamecult.eve.command_invocation.v1"; - - public EveSurfaceCommandRequest( - string providerId, - string surfaceId, - CultMeshOperationInvocationDescriptor operation, - CultMeshOperationPayload payload, - DateTimeOffset issuedAt, - string clientId, - string commandBoundary = "", - string receiptSchema = "") - : this( - SchemaId, - providerId, - surfaceId, - CultMeshOperationInvocationRecord.FromInvocation(operation), - payload?.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal) - ?? new Dictionary(StringComparer.Ordinal), - issuedAt, - clientId, - commandBoundary, - receiptSchema) - { - } - - [SerializationConstructor] - public EveSurfaceCommandRequest( - string schema, - string providerId, - string surfaceId, - CultMeshOperationInvocationRecord operation, - Dictionary payload, - DateTimeOffset issuedAt, - string clientId, - string commandBoundary, - string receiptSchema) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId; - SurfaceId = surfaceId; - OperationRecord = operation ?? throw new ArgumentNullException(nameof(operation)); - PayloadFields = payload == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(payload, StringComparer.Ordinal); - IssuedAt = issuedAt; - ClientId = clientId; - CommandBoundary = commandBoundary ?? ""; - ReceiptSchema = receiptSchema ?? ""; - } - - [Key(0)] - public string Schema { get; } - - [Key(1)] - public string ProviderId { get; } - - [Key(2)] - public string SurfaceId { get; } - - [Key(3)] - public CultMeshOperationInvocationRecord OperationRecord { get; } - - [IgnoreMember] - public CultMeshOperationInvocationDescriptor Operation => OperationRecord.ToInvocation(); - - [IgnoreMember] - public string Command => Operation.OperationId; - - [Key(4)] - public Dictionary PayloadFields { get; } - - [IgnoreMember] - public CultMeshOperationPayload Payload => new CultMeshOperationPayload(PayloadFields); - - [Key(5)] - public DateTimeOffset IssuedAt { get; } - - [Key(6)] - public string ClientId { get; } - - [Key(7)] - public string CommandBoundary { get; } - - [Key(8)] - public string ReceiptSchema { get; } - - [IgnoreMember] - public string CommandId => Operation.IdempotencyKey ?? ""; - } - - /// - /// Reads the current Eve surface envelope and the original seven-field v1 envelope. - /// - public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter - { - private const int LegacyFieldCount = 7; - private const int CurrentFieldCount = 9; - - public void Serialize(ref MessagePackWriter writer, EveSurfaceDocument? value, MessagePackSerializerOptions options) - { - if (value == null) { writer.WriteNil(); return; } - writer.WriteArrayHeader(CurrentFieldCount); - writer.Write(value.Type); - writer.Write(value.Schema); - writer.Write(value.ProviderId); - writer.Write(value.ProviderKind); - writer.Write(value.Title); - writer.Write(value.Version); - writer.Write(value.UpdatedAtUtc); - Formatter(options).Serialize(ref writer, value.Surface, options); - Formatter>(options).Serialize(ref writer, value.Commands, options); - } - - public EveSurfaceDocument? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) - { - if (reader.TryReadNil()) return null; - if (reader.NextMessagePackType != MessagePackType.Array) - throw new MessagePackSerializationException("Eve surface document must be an array."); - - options.Security.DepthStep(ref reader); - try - { - var fields = reader.ReadArrayHeader(); - if (fields == LegacyFieldCount) - { - return new EveSurfaceDocument( - ReadString(ref reader), ReadString(ref reader), ReadString(ref reader), - reader.ReadInt64(), ReadString(ref reader), - Formatter(options).Deserialize(ref reader, options)!, - Formatter>(options).Deserialize(ref reader, options) - ?? Array.Empty()); - } - - var type = ReadString(ref reader, fields, 0); - var schema = ReadString(ref reader, fields, 1); - var providerId = ReadString(ref reader, fields, 2); - var providerKind = ReadString(ref reader, fields, 3); - var title = ReadString(ref reader, fields, 4); - var version = fields > 5 ? reader.ReadInt64() : 0; - var updatedAtUtc = ReadString(ref reader, fields, 6); - var surface = fields > 7 ? Formatter(options).Deserialize(ref reader, options) : null; - var commands = fields > 8 ? Formatter>(options).Deserialize(ref reader, options) : null; - for (var index = CurrentFieldCount; index < fields; index++) reader.Skip(); - return new EveSurfaceDocument( - type, schema, providerId, providerKind, title, version, updatedAtUtc, - surface ?? throw new MessagePackSerializationException("Eve surface tree is required."), - commands ?? Array.Empty()); - } - finally { reader.Depth--; } - } - - private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => - options.Resolver.GetFormatter() - ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); - - private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; - private static string ReadString(ref MessagePackReader reader, int fields, int index) => - index < fields ? ReadString(ref reader) : ""; - } - - /// - /// Reads both direct legacy command fields and the current wrapped binding record. - /// - public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter - { - public void Serialize(ref MessagePackWriter writer, EveCommandTemplate? value, MessagePackSerializerOptions options) - { - if (value == null) { writer.WriteNil(); return; } - writer.WriteArrayHeader(1); - BindingFormatter(options).Serialize(ref writer, value.OperationRecord, options); - } - - public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) - { - if (reader.TryReadNil()) return null; - if (reader.NextMessagePackType != MessagePackType.Array) - throw new MessagePackSerializationException("Eve command template must be an array."); - - options.Security.DepthStep(ref reader); - try - { - var fields = reader.ReadArrayHeader(); - if (fields == 1 && reader.NextMessagePackType == MessagePackType.Array) - { - return new EveCommandTemplate( - BindingFormatter(options).Deserialize(ref reader, options) - ?? new CultMeshOperationBindingRecord()); - } - - var operationId = ReadString(ref reader, fields, 0); - var label = ReadString(ref reader, fields, 1); - var schemaId = ReadString(ref reader, fields, 2); - var routeKind = ReadString(ref reader, fields, 3); - var routeDescription = ReadString(ref reader, fields, 4); - for (var index = 5; index < fields; index++) reader.Skip(); - return new EveCommandTemplate(new CultMeshOperationBindingRecord( - operationId, label, schemaId, routeKind, routeDescription)); - } - finally { reader.Depth--; } - } - - private static IMessagePackFormatter BindingFormatter( - MessagePackSerializerOptions options) => - options.Resolver.GetFormatter() - ?? throw new MessagePackSerializationException("No CultMesh operation binding formatter is registered."); - - private static string ReadString(ref MessagePackReader reader, int fields, int index) => - index < fields ? reader.ReadString() ?? "" : ""; - } - -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta deleted file mode 100644 index ebd5f02..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6516c42104b235f42b15efeb4639bb73 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef deleted file mode 100644 index 7f19662..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "GameCult.Eve.Surface", - "rootNamespace": "GameCult.Eve.Surface", - "references": [ - "GameCult.Mesh" - ], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": true, - "precompiledReferences": [ - "GameCult.Caching.dll", - "GameCult.Mesh.dll", - "MessagePack.Annotations.dll", - "MessagePack.dll" - ], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": true -} diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta deleted file mode 100644 index d8f543e..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a9004278c14816345941af2f2ce673ca -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json deleted file mode 100644 index 36fd3b6..0000000 --- a/packages/org.gamecult.eve.surface/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "org.gamecult.eve.surface", - "displayName": "Eve Surface Contract", - "version": "0.2.4", - "unity": "2021.3", - "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", - "dependencies": { - "org.gamecult.cultlib": "1.0.8" - }, - "files": [ - "Runtime", - "Runtime.meta", - "README.md", - "README.md.meta", - "Directory.Build.props", - "Directory.Build.props.meta", - "GameCult.Eve.Surface.csproj", - "GameCult.Eve.Surface.csproj.meta", - "package.json.meta" - ], - "keywords": [ - "Eve", - "CultUI", - "CultMesh", - "Surface" - ], - "license": "MPL-2.0", - "category": "Scripting" -} diff --git a/packages/org.gamecult.eve.surface/package.json.meta b/packages/org.gamecult.eve.surface/package.json.meta deleted file mode 100644 index 8e89223..0000000 --- a/packages/org.gamecult.eve.surface/package.json.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 49f1738bd94075e489c82190cf660b1d -PackageManifestImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 005e991..52d0a60 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -16,14 +16,47 @@ owned by their native lowerers and are not duplicated into empty UI elements. networked client. Give it one Verse rendezvous endpoint and it discovers an advertised `interactive-world`, constructs the generic CultMesh transport, and provides every bootstrap port. Provider, surface, and Verse IDs are optional -selection filters; they are not product knowledge required by the client. +selection filters; they are not product knowledge required by the client. The +selected Verse ID and its advertised authority-runtime ID form the stable +CultMesh session target. The Eve provider ID separately names the UI owner found +through that target. Physical routes are resolved and re-resolved inside +`CultMeshClient`; they are never published as provider selection state or +cached as Unity authority. The optional local path is only an ephemeral +content/body cache, not a replicated gameplay database. Await `PrepareAsync()` before mounting the bootstrap or reading provider ports. -Discovery and session setup never run synchronously from Unity lifecycle or -input getters, so an unavailable provider cannot stall the editor main thread. -Live entity, field, and receipt polling likewise runs as one non-overlapping -background operation. Unity's main thread only applies completed typed -documents, keeping Play, Pause, inspection, and rendering responsive while a -provider is slow or temporarily unreachable. +It owns discovery, initial document reads, content transfer, asynchronous asset +bundle loading, and live lease installation. Once preparation succeeds, +`Mount()` and `Connect()` perform no remote work. Input submission only enters a +bounded asynchronous outbox: one-shot commands retain their idempotency keys, +while movement and look values coalesce to the latest value per entity. A +successful send is not completion: the command remains pending and retries with +the same idempotency key until its canonical provider receipt arrives. Failed +or unacknowledged commands rotate behind other due work, so one poison command +cannot starve the outbox. +Live entity, field, and receipt polling likewise remains non-blocking. Unity's +main thread applies completed typed documents and assets; it never waits for a +CultMesh task, keeping Play, Pause, input, inspection, and rendering responsive +while a provider is slow or temporarily unreachable. + +An Eve world may qualify its asset catalog with a different provider, Verse, +authority runtime, and Odin rendezvous set than the surface provider. The +transport resolves that exact asset session and commits assets as one generation: +catalog, verified bundles, leases, lookups, render policy, and surface lowering +are prepared off to the side, then swapped together. A failed or superseded +candidate is disposed without unloading or rewriting the mounted generation. +The mounted provider's trust policy authenticates only that provider. A +provider-qualified asset source in another Verse/runtime uses the configured +cross-target Odin trust for its catalog, subscription, manifests, content, and +bundles; local-development trust does not cross that boundary. +The candidate opens its catalog lease before preload, observes updates in a +candidate-owned buffer, and verifies the leased version again before commit. +An update that lands during preload therefore rebuilds the candidate instead +of disappearing between a one-shot read and a later subscription. + +Navigation keeps the outgoing presentation quiesced until the candidate route +is ready, then commits the provider route before activating the candidate. +Presentation connections are provider-owned leases: disposing the outgoing +host releases only its lease and cannot disconnect the newly committed Verse. The package never imports provider product assemblies. @@ -143,3 +176,13 @@ authority. Native volume programs may advertise a `cameraToWorld` matrix port when their procedural passes require an explicit camera transform instead of relying on render-pipeline globals. + +Command finality is presentation-causal. When a terminal receipt names a +`sourceVersion` newer than the mounted surface, EveUnity keeps the command and +its exact receipt lease alive, makes the obsolete surface read-only, and emits +the terminal receipt only after that surface generation and its qualified asset +catalog have committed together. Asset preparation failure therefore cannot +leave an accepted Verse switch paired with actionable controls from the prior +Verse. Embedded surfaces may recompose the visible tree, but their versions are +derived rendering inputs and cannot satisfy or clear the advertised base +surface's finality barrier. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs new file mode 100644 index 0000000..f8c89fa --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + /// + /// Owns receipt-backed asynchronous command delivery for the CultMesh Unity + /// transport. A successful transport send only moves a command into + /// AwaitingCanonicalReceipt. The provider receipt is the sole completion + /// signal. Retries retain the original idempotency key, and one failed + /// command cannot monopolize delivery of the rest of the bounded outbox. + /// + internal sealed class EveUnityCultMeshCommandOutbox : IDisposable + { + internal enum DeliveryState + { + Queued, + Sending, + AwaitingCanonicalReceipt + } + + private readonly object _gate = new object(); + private readonly List _items = new List(); + private readonly Dictionary _byCommandId = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _continuous = + new Dictionary(StringComparer.Ordinal); + private readonly Func _send; + private readonly Func _continuousKey; + private readonly Action _discard; + private readonly int _commandCapacity; + private readonly int _continuousCapacity; + private readonly TimeSpan _retryDelay; + private readonly TimeSpan _maximumRetryDelay; + private readonly TimeSpan _sendAttemptTimeout; + private readonly SemaphoreSlim _changed = new SemaphoreSlim(0); + private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); + private readonly Task _worker; + private bool _disposed; + private long _sequence; + + public EveUnityCultMeshCommandOutbox( + Func send, + Func continuousKey, + Action? discard = null, + int commandCapacity = 256, + int continuousCapacity = 32, + TimeSpan? retryDelay = null, + TimeSpan? maximumRetryDelay = null, + TimeSpan? sendAttemptTimeout = null) + { + _send = send ?? throw new ArgumentNullException(nameof(send)); + _continuousKey = continuousKey ?? throw new ArgumentNullException(nameof(continuousKey)); + _discard = discard ?? (_ => { }); + if (commandCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(commandCapacity)); + if (continuousCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(continuousCapacity)); + _commandCapacity = commandCapacity; + _continuousCapacity = continuousCapacity; + _retryDelay = retryDelay ?? TimeSpan.FromMilliseconds(100); + _maximumRetryDelay = maximumRetryDelay ?? TimeSpan.FromSeconds(2); + _sendAttemptTimeout = sendAttemptTimeout ?? TimeSpan.FromSeconds(2); + if (_retryDelay <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(retryDelay)); + if (_maximumRetryDelay < _retryDelay) throw new ArgumentOutOfRangeException(nameof(maximumRetryDelay)); + if (_sendAttemptTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(sendAttemptTimeout)); + _worker = Task.Run(DeliverAsync); + } + + internal int PendingCount + { + get { lock (_gate) return _items.Count; } + } + + internal DeliveryState? StateOf(string commandId) + { + lock (_gate) + return _byCommandId.TryGetValue(commandId, out var item) ? item.State : null; + } + + public void Enqueue(EveSurfaceCommandRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.CommandId)) + throw new InvalidOperationException("Eve command invocations require an idempotency key."); + + string? discarded = null; + lock (_gate) + { + if (_disposed) throw new ObjectDisposedException(nameof(EveUnityCultMeshCommandOutbox)); + if (_byCommandId.ContainsKey(request.CommandId)) + throw new InvalidOperationException($"Eve command '{request.CommandId}' is already pending."); + + var key = _continuousKey(request); + key = string.IsNullOrWhiteSpace(key) ? null : key; + if (key == null) + { + if (_items.Count(item => item.ContinuousKey == null) >= _commandCapacity) + throw new InvalidOperationException( + $"The EveUnity command outbox reached its {_commandCapacity}-command bound."); + } + else if (_continuous.TryGetValue(key, out var previous)) + { + RemoveLocked(previous); + discarded = previous.Request.CommandId; + } + else if (_continuous.Count >= _continuousCapacity) + { + throw new InvalidOperationException( + $"The EveUnity continuous-control outbox reached its {_continuousCapacity}-binding bound."); + } + + var item = new OutboxItem(request, key, ++_sequence); + _items.Add(item); + _byCommandId.Add(request.CommandId, item); + if (key != null) _continuous[key] = item; + } + + if (discarded != null) _discard(discarded); + _changed.Release(); + } + + /// Completes a command only when its canonical provider receipt arrives. + public bool Acknowledge(string commandId) + { + if (string.IsNullOrWhiteSpace(commandId)) return false; + lock (_gate) + { + if (!_byCommandId.TryGetValue(commandId, out var item)) return false; + RemoveLocked(item); + } + _changed.Release(); + return true; + } + + public void Dispose() + { + OutboxItem[] discarded; + lock (_gate) + { + if (_disposed) return; + _disposed = true; + discarded = _items.ToArray(); + _items.Clear(); + _byCommandId.Clear(); + _continuous.Clear(); + } + foreach (var item in discarded) _discard(item.Request.CommandId); + _lifetime.Cancel(); + _changed.Release(); + } + + private async Task DeliverAsync() + { + var cancellationToken = _lifetime.Token; + try + { + while (true) + { + var selection = TakeNext(); + if (selection.Item == null) + { + await WaitForChangeOrDueAsync(selection.Delay, cancellationToken).ConfigureAwait(false); + continue; + } + await DeliverOnceAsync(selection.Item, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task DeliverOnceAsync(OutboxItem item, CancellationToken cancellationToken) + { + var sent = false; + using (var attempt = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + attempt.CancelAfter(_sendAttemptTimeout); + try + { + await _send(item.Request, attempt.Token).ConfigureAwait(false); + sent = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // The same idempotency key is retried after other due items. + } + } + + lock (_gate) + { + if (!_byCommandId.TryGetValue(item.Request.CommandId, out var current) || + !ReferenceEquals(item, current)) + return; + item.Attempts++; + item.State = sent ? DeliveryState.AwaitingCanonicalReceipt : DeliveryState.Queued; + item.NextAttemptAt = DateTimeOffset.UtcNow + RetryDelay(item.Attempts); + item.Sequence = ++_sequence; + } + _changed.Release(); + } + + private Selection TakeNext() + { + lock (_gate) + { + var now = DateTimeOffset.UtcNow; + var item = _items + .Where(candidate => candidate.State != DeliveryState.Sending && candidate.NextAttemptAt <= now) + .OrderBy(candidate => candidate.Sequence) + .FirstOrDefault(); + if (item != null) + { + item.State = DeliveryState.Sending; + return new Selection(item, null); + } + var next = _items + .Where(candidate => candidate.State != DeliveryState.Sending) + .Select(candidate => (DateTimeOffset?)candidate.NextAttemptAt) + .OrderBy(value => value) + .FirstOrDefault(); + return new Selection(null, next.HasValue ? Max(TimeSpan.Zero, next.Value - now) : null); + } + } + + private async Task WaitForChangeOrDueAsync(TimeSpan? delay, CancellationToken cancellationToken) + { + if (!delay.HasValue) + { + await _changed.WaitAsync(cancellationToken).ConfigureAwait(false); + return; + } + var milliseconds = (int)Math.Min(int.MaxValue, Math.Max(1, delay.Value.TotalMilliseconds)); + await _changed.WaitAsync(milliseconds, cancellationToken).ConfigureAwait(false); + } + + private TimeSpan RetryDelay(int attempts) + { + var multiplier = Math.Pow(2, Math.Min(attempts - 1, 10)); + return TimeSpan.FromMilliseconds(Math.Min( + _maximumRetryDelay.TotalMilliseconds, + _retryDelay.TotalMilliseconds * multiplier)); + } + + private void RemoveLocked(OutboxItem item) + { + _items.Remove(item); + _byCommandId.Remove(item.Request.CommandId); + if (item.ContinuousKey != null && + _continuous.TryGetValue(item.ContinuousKey, out var current) && + ReferenceEquals(item, current)) + _continuous.Remove(item.ContinuousKey); + } + + private static TimeSpan Max(TimeSpan left, TimeSpan right) => left >= right ? left : right; + + private sealed class OutboxItem + { + public OutboxItem(EveSurfaceCommandRequest request, string? continuousKey, long sequence) + { + Request = request; + ContinuousKey = continuousKey; + Sequence = sequence; + NextAttemptAt = DateTimeOffset.MinValue; + } + + public EveSurfaceCommandRequest Request { get; } + public string? ContinuousKey { get; } + public DeliveryState State { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } + public int Attempts { get; set; } + public long Sequence { get; set; } + } + + private readonly struct Selection + { + public Selection(OutboxItem? item, TimeSpan? delay) + { + Item = item; + Delay = delay; + } + + public OutboxItem? Item { get; } + public TimeSpan? Delay { get; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta new file mode 100644 index 0000000..a6c7e7a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 61d76e98f2054d86bf8e7480d4411c39 +timeCreated: 1787039650 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs new file mode 100644 index 0000000..5e163c8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs @@ -0,0 +1,49 @@ +using System; +using GameCult.Mesh; +using GameCult.Networking; +using GameCult.Networking.WebSockets; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + /// + /// Owns the transport set used by EveUnity for both rendezvous discovery and + /// the selected provider. Trust remains caller-owned; this class only makes + /// every supported secure plane reachable through the same configuration. + /// + internal static class EveUnityCultMeshConnectivity + { + public static CultMeshVerseDiscoveryClientOptions Discovery() => new() + { + CreateClientForEndpoint = CreateSchemaClient + }; + + public static ICultMeshTransportConnector[] SchemaConnectors() => new ICultMeshTransportConnector[] + { + new CultMeshTcpSchemaTransportConnector(), + new CultMeshUriSchemaTransportConnector( + "cultnet-websocket", + new[] { "ws", "wss" }, + _ => new CultNetWebSocketSchemaClient()) + }; + + public static ICultMeshContentTransportConnector[] ContentConnectors() => + new ICultMeshContentTransportConnector[] + { + new CultMeshHttpsContentTransportConnector(), + new CultMeshTcpContentTransportConnector() + }; + + private static ICultNetSchemaClient CreateSchemaClient(string endpoint) + { + if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && + (string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase))) + { + return new CultNetWebSocketSchemaClient(); + } + return CultNetSchemaClients.CreateForEndpoint(endpoint); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta new file mode 100644 index 0000000..a701492 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f7cb06716fd4f319ade147a69acb74a diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index edf1e38..5a403f4 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -7,13 +7,11 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; -using GameCult.Caching.MessagePack; using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; using GameCult.Eve.UnityScene.Fields; using GameCult.Mesh; using GameCult.Mesh.Quic.Native; -using GameCult.Networking; using UnityEngine; using Stopwatch = System.Diagnostics.Stopwatch; @@ -26,45 +24,24 @@ namespace GameCult.Eve.UnityScene IEveUnityGameObjectAssetProvider, IDisposable { - private const string RemoteShardId = "provider"; + private const int AssetPresentationAttemptLimit = 3; - private static readonly Type[] WireDocumentTypes = - { - typeof(EveProviderAdvertisementDocument), - typeof(EveSurfaceDocument), - typeof(EveInputCapabilityDocument), - typeof(EveSurfaceCommandRequest), - typeof(EveCommandReceiptDocument), - typeof(EveAssetCatalogDocument), - typeof(EveEntitySoaViewDocument), - typeof(EveFieldsSplatsDocument), - typeof(CultMeshBodyPublicationDocument), - typeof(CultMeshCdnArtifactManifest), - typeof(CultMeshContentTransferStateDocument) - }; - - private readonly string _replicaPath; - private readonly string _endpoint; + private readonly string _cachePath; + private readonly string _rendezvousEndpoint; + private readonly CultMeshSessionTarget _target; private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; - private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); - private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); - private readonly Dictionary _prefabs = new Dictionary(StringComparer.Ordinal); - private readonly Dictionary _nativeAssets = new Dictionary(StringComparer.Ordinal); - private readonly Dictionary> _nativeAssetMetadata = - new Dictionary>(StringComparer.Ordinal); - private readonly List _assetBundles = new List(); - private readonly Dictionary _assetSelections = - new Dictionary(StringComparer.Ordinal); - private readonly Dictionary> _bundleSelections = - new Dictionary>(StringComparer.Ordinal); - private readonly Dictionary _bundleVariants = - new Dictionary(StringComparer.Ordinal); - private readonly HashSet _loadedBundleUris = new HashSet(StringComparer.Ordinal); - private readonly HashSet _loadingBundleUris = new HashSet(StringComparer.Ordinal); - private readonly List _assetBodyLeases = new List(); - private readonly Dictionary _renderChannelLayers = new Dictionary(StringComparer.Ordinal); + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; + private readonly CultMeshAuthorityTrustPolicy _crossTargetAuthorityTrust; + private readonly Dictionary _pendingCommands = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _receiptSubscriptions = + new Dictionary>(StringComparer.Ordinal); + private readonly object _presentationFinalityGate = new object(); + private readonly Dictionary _deferredTerminalReceipts = + new Dictionary(StringComparer.Ordinal); + private readonly HashSet _continuousCommandIds = new HashSet(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); private readonly object _realtimeFrameGate = new object(); private readonly Dictionary _latestRealtimeFrames = @@ -73,17 +50,14 @@ namespace GameCult.Eve.UnityScene new HashSet(StringComparer.Ordinal); private readonly Dictionary _embeddedSurfaces = new Dictionary(StringComparer.Ordinal); - private CultMeshNode? _node; - private CultMeshSnapshotSession? _snapshot; - private CultNetDocumentRegistry? _networkRegistry; - private CultNetShardDescriptor? _replicaShard; - private CultNetDatabaseSubscriptionClient? _subscriptions; - private CultNetDatabaseSubscriptionClient? _entitySubscriptions; - private CultMeshLiveBody? _entityBody; - private CultNetLiveValue? _inputCapability; + private readonly List _documentLeases = new List(); + private readonly List _documentWatches = new List(); + private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); + private readonly SemaphoreSlim _assetGenerationGate = new SemaphoreSlim(1, 1); private CultMeshClient? _meshClient; - private CultMeshContentTransferService? _contentTransfer; - private CultMeshVerifiedBodyMappingBroker? _assetBodyMappings; + private bool _liveDocumentsReady; + private CultCache? _contentState; + private AssetGeneration _assetGeneration; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; private EveSurfaceDocument? _baseSurface; @@ -93,8 +67,19 @@ namespace GameCult.Eve.UnityScene private CultMeshMappedFrameBodyCursor? _mappedEntityFrameCursor; private CultMeshBodyDescriptor? _mappedEntityFrameContract; private CancellationTokenSource? _realtimeLifetime; + private CancellationTokenSource? _surfaceAssetCandidateLifetime; private Task? _realtimePump; private CultMeshRealtimeSession? _realtimeSession; + private EveUnityCultMeshCommandOutbox? _commandOutbox; + private PendingAssetCatalogUpdate? _pendingAssetCatalog; + private bool _assetCatalogUpdateRunning; + private long _surfaceAssetGeneration; + private long _highestObservedBaseSurfaceVersion = -1; + private long _activeBaseSurfaceCandidateVersion = -1; + private long _activeBaseSurfaceCandidateGeneration = -1; + private int _lastAssetPresentationAttemptCount; + private long _mountedBaseSurfaceVersion; + private long _presentationBarrierVersion = -1; private long _lastQueuedEntityViewEpoch = -1; private long _lastQueuedEntityViewSequence = -1; private long _lastPresentedEntityViewEpoch = -1; @@ -103,33 +88,46 @@ namespace GameCult.Eve.UnityScene private bool _disposed; public EveUnityCultMeshLiveProviderTransport( - string replicaPath, - string endpoint, + string cachePath, + string rendezvousEndpoint, + string verseId, + string authorityRuntimeId, string providerId, string surfaceId, string runtimeId = "eve-unity", - CultMeshBodyPublicationResolver? bodyResolver = null) + CultMeshBodyPublicationResolver? bodyResolver = null, + CultMeshAuthorityTrustPolicy? authorityTrust = null, + CultMeshAuthorityTrustPolicy? crossTargetAuthorityTrust = null) { - _replicaPath = string.IsNullOrWhiteSpace(replicaPath) - ? throw new ArgumentException("Replica path must be non-empty.", nameof(replicaPath)) - : Path.GetFullPath(replicaPath); - _endpoint = string.IsNullOrWhiteSpace(endpoint) - ? throw new ArgumentException("CultMesh endpoint must be non-empty.", nameof(endpoint)) - : endpoint.Trim(); + _cachePath = string.IsNullOrWhiteSpace(cachePath) + ? throw new ArgumentException("Cache path must be non-empty.", nameof(cachePath)) + : Path.GetFullPath(cachePath); + _rendezvousEndpoint = string.IsNullOrWhiteSpace(rendezvousEndpoint) + ? throw new ArgumentException("CultMesh rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)) + : rendezvousEndpoint.Trim(); + _target = new CultMeshSessionTarget(verseId, authorityRuntimeId); _providerId = string.IsNullOrWhiteSpace(providerId) ? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)) : providerId.Trim(); + _assetGeneration = new AssetGeneration( + new AssetSource(_target, _providerId, "", Array.Empty()), + sourceIdentity: "", + catalogVersion: -1, + meshClient: null); _surfaceId = string.IsNullOrWhiteSpace(surfaceId) ? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId)) : surfaceId.Trim(); _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); _bodyResolver = bodyResolver; + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + _crossTargetAuthorityTrust = crossTargetAuthorityTrust ?? _authorityTrust; CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); CurrentInputCapability = new EveInputCapabilityDocument(); } - public string TransportKind => "eve-cultmesh-remote-replica"; + public string TransportKind => "eve-cultmesh-identity-session"; public string SurfacePointer => CurrentSurfaceDocument.SourcePointer; @@ -141,7 +139,7 @@ namespace GameCult.Eve.UnityScene public EveInputCapabilityDocument CurrentInputCapability { get; private set; } - public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind { get; private set; } + public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind => _assetGeneration.BodyTransportKind; public event Action? SurfaceDocumentAvailable; @@ -166,8 +164,7 @@ namespace GameCult.Eve.UnityScene public void Connect() { - EnsureOpen(); - BootstrapIfRequired(); + EnsurePrepared(); } public void Disconnect() @@ -176,44 +173,48 @@ namespace GameCult.Eve.UnityScene public void Refresh() { - EnsureOpen(); - if (_bootstrapped) - { - PumpLiveEvents(); - return; - } - - BootstrapIfRequired(); + EnsurePrepared(); + PumpLiveEvents(); } - private void BootstrapIfRequired() + public async Task PrepareAsync(CancellationToken cancellationToken = default) { if (_bootstrapped) return; try { - _snapshot ??= CreateSnapshotSession(); + EnsureOpen(); var elapsed = Stopwatch.StartNew(); - ResolveAdvertisement(forceRefresh: true); + await ResolveAdvertisementAsync(forceRefresh: true, cancellationToken); TraceStartup("advertisement", elapsed); - RefreshSurface(); + await RefreshSurfaceAsync(cancellationToken); TraceStartup("surface", elapsed); - RefreshAssetCatalog(); + await RefreshAssetCatalogAsync(cancellationToken); TraceStartup("asset-catalog-and-bundles", elapsed); - EnsureLiveSubscriptions(); + await EnsureLiveDocumentsAsync(cancellationToken); TraceStartup("subscriptions", elapsed); + _commandOutbox = new EveUnityCultMeshCommandOutbox( + SendCommandAsync, + ContinuousCommandKey, + ForgetPendingCommand); _bootstrapped = true; } catch (Exception error) when (error is IOException || error is SocketException || error is TimeoutException) { - ResetSnapshotSession(); throw new InvalidOperationException( - $"CultMesh provider '{_providerId}' bootstrap transport failed; the snapshot lane was reset for retry.", + $"CultMesh provider '{_providerId}' bootstrap through target '{_target}' failed.", error); } } + private void EnsurePrepared() + { + if (!_bootstrapped) + throw new InvalidOperationException( + "The EveUnity CultMesh transport is not prepared. Await PrepareAsync before mounting or connecting it."); + } + private static void TraceStartup(string phase, Stopwatch elapsed) { if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal)) @@ -237,8 +238,6 @@ namespace GameCult.Eve.UnityScene public void PumpLiveEvents() { - ThrowIfSubscriptionFailed(_entitySubscriptions); - ThrowIfSubscriptionFailed(_subscriptions); while (_liveDocuments.TryDequeue(out var document)) { if (document is PendingEntityGeneration entityGeneration) @@ -257,7 +256,7 @@ namespace GameCult.Eve.UnityScene else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; else if (document is EveAssetCatalogDocument assetCatalog) - PublishAssetCatalog(assetCatalog); + QueueAssetCatalogUpdate(CurrentAssetSourceIdentity, assetCatalog); else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); else if (document is Exception error) @@ -266,15 +265,6 @@ namespace GameCult.Eve.UnityScene PumpMappedEntityFrame(); } - private static void ThrowIfSubscriptionFailed(CultNetDatabaseSubscriptionClient? subscription) - { - var failure = subscription?.BackgroundFailure; - if (failure?.IsCompletedSuccessfully == true) - throw new InvalidOperationException( - "The retained CultMesh state subscription failed.", - failure.Result); - } - private void QueueEntityView(EveEntitySoaViewDocument document) { TraceHotState($"layout epoch={document.ProducerEpoch} sequence={document.Sequence} identities={document.Identities?.Length ?? 0}"); @@ -372,8 +362,8 @@ namespace GameCult.Eve.UnityScene public void SubmitCommand(EveSurfaceCommandRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); - EnsureOpen(); - ResolveAdvertisement(); + EnsurePrepared(); + EnsurePresentationWritable(); var interaction = RequireWorldInteraction(); if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef)) throw new InvalidOperationException("The provider advertisement does not publish a command record reference."); @@ -387,153 +377,163 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(commandId)) throw new InvalidOperationException("Eve command invocations require an idempotency key."); - _pendingCommandIds.Add(commandId); - try + lock (_pendingCommands) { - var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); - var message = _networkRegistry!.CreateRawDocumentPutMessage( - $"eve-unity-{commandId}", - new CultRecordHandle(new CultRecordKey(recordKey)), - request, - new CultNetDocumentMessageOptions - { - SourceRuntimeId = _runtimeId, - SourceRole = "eve-unity" - }); - new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions()) - .ForwardPutAsync(_replicaShard!, message) - .GetAwaiter() - .GetResult(); + if (_pendingCommands.ContainsKey(commandId)) + throw new InvalidOperationException($"Eve command '{commandId}' is already pending."); + _pendingCommands.Add(commandId, request); } + try { _commandOutbox!.Enqueue(request); } catch { - _pendingCommandIds.Remove(commandId); + ForgetPendingCommand(commandId); throw; } } + private Task SendCommandAsync(EveSurfaceCommandRequest request, CancellationToken cancellationToken) + { + return SendCommandWithReceiptLeaseAsync(request, cancellationToken); + } + + private async Task SendCommandWithReceiptLeaseAsync( + EveSurfaceCommandRequest request, + CancellationToken cancellationToken) + { + await EnsureReceiptSubscriptionAsync(request.CommandId, cancellationToken).ConfigureAwait(false); + var interaction = RequireWorldInteraction(); + var recordKey = ChildRecordKey(interaction.CommandRecordRef, request.CommandId); + await _meshClient!.SubmitDocumentAsync( + _target, + recordKey, + request, + _runtimeId, + "eve-unity", + cancellationToken) + .ConfigureAwait(false); + } + + private string? ContinuousCommandKey(EveSurfaceCommandRequest request) + { + if (!request.PayloadFields.TryGetValue("commandId", out var commandId) || + !_continuousCommandIds.Contains(commandId)) + return null; + request.PayloadFields.TryGetValue("entityId", out var entityId); + return commandId + "\u001f" + (entityId ?? ""); + } + + private void ForgetPendingCommand(string commandId) + { + Task? subscription = null; + lock (_pendingCommands) + { + _pendingCommands.Remove(commandId); + if (_receiptSubscriptions.TryGetValue(commandId, out subscription)) + _receiptSubscriptions.Remove(commandId); + } + if (subscription != null) DisposeReceiptSubscription(subscription); + } + public void Dispose() { _disposed = true; - foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); - _assetBundles.Clear(); - foreach (var lease in _assetBodyLeases) lease.Dispose(); - _assetBodyLeases.Clear(); - _prefabs.Clear(); - _nativeAssets.Clear(); - _nativeAssetMetadata.Clear(); - _assetSelections.Clear(); - _bundleSelections.Clear(); - _bundleVariants.Clear(); - _loadedBundleUris.Clear(); - _loadingBundleUris.Clear(); + _lifetime.Cancel(); + _commandOutbox?.Dispose(); + _commandOutbox = null; + _assetGeneration.Dispose(); _mappedEntityFrameCursor?.Dispose(); _mappedEntityFrameCursor = null; _mappedEntityFrameContract = null; _realtimeLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Dispose(); + _surfaceAssetCandidateLifetime = null; _realtimeSession?.Dispose(); _realtimeSession = null; - if (_realtimePump != null) - { - try { _realtimePump.GetAwaiter().GetResult(); } - catch (OperationCanceledException) { } - } _realtimePump = null; _realtimeLifetime?.Dispose(); _realtimeLifetime = null; - _entityBody?.Dispose(); - _entityBody = null; - _inputCapability?.Dispose(); - _inputCapability = null; - _subscriptions?.Dispose(); - _entitySubscriptions?.Dispose(); - _node?.Dispose(); - _snapshot?.Dispose(); + DisposeLiveDocuments(); + _contentState?.Dispose(); + _contentState = null; _meshClient?.Dispose(); - _node = null; - _snapshot = null; - _subscriptions = null; - _entitySubscriptions = null; _meshClient = null; - _contentTransfer = null; - _assetBodyMappings = null; - _networkRegistry = null; - _replicaShard = null; _baseSurface = null; _embeddedSurfaces.Clear(); - _pendingCommandIds.Clear(); - _publishedReceiptIds.Clear(); + lock (_pendingCommands) + { + _pendingCommands.Clear(); + foreach (var subscription in _receiptSubscriptions.Values) + DisposeReceiptSubscription(subscription); + _receiptSubscriptions.Clear(); + } + lock (_presentationFinalityGate) + { + _deferredTerminalReceipts.Clear(); + _presentationBarrierVersion = -1; + } + _lifetime.Dispose(); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { if (asset == null) throw new ArgumentNullException(nameof(asset)); - EnsureAssetLoaded(asset.AssetRef); - return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; + EnsureAssetPrepared(asset.AssetRef); + return _assetGeneration.Prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; } public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType) { if (asset == null) throw new ArgumentNullException(nameof(asset)); if (assetType == null) throw new ArgumentNullException(nameof(assetType)); - EnsureAssetLoaded(asset.AssetRef); - return _nativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) + EnsureAssetPrepared(asset.AssetRef); + return _assetGeneration.NativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) ? value : null; } + private void EnsureAssetPrepared(string assetRef) + { + EnsurePrepared(); + if (_assetGeneration.AssetSelections.ContainsKey(assetRef) && + !_assetGeneration.NativeAssets.ContainsKey(assetRef)) + throw new InvalidOperationException( + $"Provider asset '{assetRef}' was not materialized during asynchronous preparation."); + } + public bool TryResolveAssetMetadata( EveUnityPlayableWorldAssetBinding asset, out IReadOnlyDictionary metadata) { if (asset == null) throw new ArgumentNullException(nameof(asset)); - return _nativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!); + return _assetGeneration.NativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!); } public bool TryGetRenderChannelLayer(string channel, out int layer) { - return _renderChannelLayers.TryGetValue(channel ?? "", out layer); + return _assetGeneration.RenderChannelLayers.TryGetValue(channel ?? "", out layer); } private void EnsureOpen() { - if (_node != null) + if (_meshClient != null) return; + _meshClient = CreateMeshClient(new[] { _rendezvousEndpoint }, _authorityTrust); + } - var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(WireDocumentTypes); - _networkRegistry = CultMesh.CreateCultNetDocumentRegistry(WireDocumentTypes, cacheRegistry); - _replicaShard = new CultNetShardDescriptor( - RemoteShardId, - _providerId, - epoch: 1, - isPrimary: false, - schemaIds: WireDocumentTypes.Select(type => cacheRegistry.GetRequired(type).SchemaId), - primaryEndpoints: new[] { _endpoint }); - _node = CultMesh.CreateNodeAsync( - _replicaPath, - new CultMeshNodeOptions - { - StartServer = false, - EnableDurableShardLogs = true, - CacheOptions = new CultCacheOpenOptions - { - Registry = cacheRegistry, - PullOnOpen = false, - StoreFlushOnDispose = true, - UseDirectoryStore = true - }, - DatabaseOptions = new CultNetDatabaseOptions - { - RuntimeId = _runtimeId, - Shards = new[] { _replicaShard }, - DocumentRegistry = _networkRegistry - } - }) - .GetAwaiter() - .GetResult(); - _snapshot = CreateSnapshotSession(); - _meshClient = new CultMeshClient(new CultMeshClientOptions + private CultMeshClient CreateMeshClient( + IReadOnlyList rendezvousEndpoints, + CultMeshAuthorityTrustPolicy authorityTrust) + { + return new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = new[] { _endpoint }, + RendezvousEndpoints = rendezvousEndpoints, + Discovery = EveUnityCultMeshConnectivity.Discovery(), + Sessions = new CultMeshSessionManagerOptions + { + Trust = authorityTrust ?? throw new ArgumentNullException(nameof(authorityTrust)) + }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors(), RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] { new CultMeshNativeQuicRealtimeTransportConnector() @@ -541,35 +541,19 @@ namespace GameCult.Eve.UnityScene }); } - private CultMeshSnapshotSession CreateSnapshotSession() => - CultMesh.SnapshotSession( - _endpoint, - new CultMeshSnapshotRequestOptions - { - ShardId = RemoteShardId, - ShardEpoch = 1, - ConnectTimeout = TimeSpan.FromSeconds(5), - ResponseTimeout = TimeSpan.FromSeconds(30), - MessageIdPrefix = "eve-unity", - RudpRuntimeId = _runtimeId, - RudpMaxFragmentBytes = 1024 - }, - _networkRegistry); + private CultMeshClient AssetMeshClient(AssetGeneration? generation = null) => + (generation ?? _assetGeneration).MeshClient ?? + _meshClient ?? throw new InvalidOperationException("The CultMesh client is not open."); - private void ResetSnapshotSession() - { - _snapshot?.Dispose(); - _snapshot = null; - } - - private void ResolveAdvertisement(bool forceRefresh = false) + private async Task ResolveAdvertisementAsync( + bool forceRefresh = false, + CancellationToken cancellationToken = default) { if (!forceRefresh && _advertisement != null && _advertisedSurface != null) return; - var documents = _snapshot! - .FetchDocumentsAsync() - .GetAwaiter() - .GetResult(); + using var lease = await _meshClient! + .LeaseCollectionAsync(_target, cancellationToken); + var documents = await lease.Handle.LatestAsync(); _advertisement = documents.FirstOrDefault(document => string.Equals(document.ProviderId, _providerId, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"Provider '{_providerId}' did not publish an Eve advertisement."); @@ -588,10 +572,10 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId)) throw new InvalidOperationException( $"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for body transport."); - var mappedRoot = Path.GetDirectoryName(_replicaPath) ?? "."; + var mappedRoot = _cachePath; var networkBodies = _meshClient!.BodyProvider( _advertisement.ProviderId, - _advertisement.ServiceId, + _target, new CultMeshSessionBodyProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(2) }); return new CultMeshBodyPublicationResolver(new CultMeshBodyTransportService( new ICultMeshBodyTransportAdapter[] @@ -637,7 +621,7 @@ namespace GameCult.Eve.UnityScene EveEntitySoaViewDocument document, CultMeshBodyPublicationDocument publication) { - ResolveAdvertisement(); + EnsurePrepared(); if (!string.Equals(document.ProviderId, _advertisement!.ProviderId, StringComparison.Ordinal)) throw new UnauthorizedAccessException( $"Entity layout provider '{document.ProviderId}' does not match advertised Eve provider '{_advertisement.ProviderId}'."); @@ -705,19 +689,20 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException( $"Eve provider '{_providerId}' does not advertise the Verse identity required for realtime state."); _realtimeLifetime = new CancellationTokenSource(); - _realtimePump = Task.Run(() => PumpRealtimeEntityStateAsync( - _advertisement.VerseId, - _realtimeLifetime.Token)); + if (!string.Equals(_advertisement.VerseId, _target.VerseId, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Eve provider '{_providerId}' advertisement Verse '{_advertisement.VerseId}' does not match selected Verse '{_target.VerseId}'."); + _realtimePump = Task.Run(() => PumpRealtimeEntityStateAsync(_realtimeLifetime.Token)); } - private async Task PumpRealtimeEntityStateAsync(string verseId, CancellationToken cancellationToken) + private async Task PumpRealtimeEntityStateAsync(CancellationToken cancellationToken) { try { _realtimeSession = await _meshClient! - .ConnectRealtimeAsync(verseId, cancellationToken) + .ConnectRealtimeAsync(_target, cancellationToken) .ConfigureAwait(false); - TraceHotState($"realtime state connected transport={_realtimeSession.TransportId} verse={verseId}"); + TraceHotState($"realtime state connected transport={_realtimeSession.TransportId} target={_target}"); while (!cancellationToken.IsCancellationRequested) { var frame = await _realtimeSession.ReceiveAsync(cancellationToken).ConfigureAwait(false); @@ -921,23 +906,78 @@ namespace GameCult.Eve.UnityScene DateTimeOffset now) => publication.LivenessExpiresAtUnixMs > now.ToUnixTimeMilliseconds(); - private void RefreshSurface() + private async Task RefreshSurfaceAsync(CancellationToken cancellationToken) { - var surface = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { _advertisedSurface!.RecordRef }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }) - .GetAwaiter() - .GetResult() - .FirstOrDefault() - ?? throw new InvalidOperationException("The advertised Eve surface record was not available."); + var surface = await _meshClient! + .ReadAsync(_target, _advertisedSurface!.RecordRef, cancellationToken); PublishBaseSurface(surface); } private void PublishBaseSurface(EveSurfaceDocument surface) { - _baseSurface = surface ?? throw new ArgumentNullException(nameof(surface)); - PublishComposedSurface(); + if (surface == null) throw new ArgumentNullException(nameof(surface)); + if (!TryBeginBaseSurfaceCandidate(surface.Version, out var generation)) + return; + CancellationToken candidateToken; + lock (_presentationFinalityGate) + { + _surfaceAssetCandidateLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Dispose(); + _surfaceAssetCandidateLifetime = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + candidateToken = _surfaceAssetCandidateLifetime.Token; + } + if (!_bootstrapped) + { + _baseSurface = surface; + PublishComposedSurface(surface.Version); + return; + } + + var source = ResolveAssetSource(surface); + if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal) && + (source.RequiredCatalogVersion <= 0 || source.RequiredCatalogVersion == CurrentAssetCatalogVersion)) + { + _baseSurface = surface; + PublishComposedSurface(surface.Version); + return; + } + + // A surface and its provider-qualified asset catalog are one presentation + // candidate. Keep the previous surface mounted until the candidate assets + // have resolved and preloaded; never lower a remote Verse surface against + // the previous Verse's catalog merely because the surface record arrived first. + BeginPresentationTransition(surface.Version); + _ = RefreshAssetsForSurfaceAsync(surface, source, generation, candidateToken); + } + + private bool TryBeginBaseSurfaceCandidate(long version, out long generation) + { + lock (_presentationFinalityGate) + { + generation = -1; + if (version < _highestObservedBaseSurfaceVersion || + version <= _mountedBaseSurfaceVersion || + (version == _highestObservedBaseSurfaceVersion && + _activeBaseSurfaceCandidateVersion == version)) + return false; + _highestObservedBaseSurfaceVersion = Math.Max(_highestObservedBaseSurfaceVersion, version); + generation = Interlocked.Increment(ref _surfaceAssetGeneration); + _activeBaseSurfaceCandidateVersion = version; + _activeBaseSurfaceCandidateGeneration = generation; + return true; + } + } + + private void RetireFailedBaseSurfaceCandidate(long version, long generation) + { + lock (_presentationFinalityGate) + { + if (_activeBaseSurfaceCandidateVersion != version || + _activeBaseSurfaceCandidateGeneration != generation) + return; + _activeBaseSurfaceCandidateVersion = -1; + _activeBaseSurfaceCandidateGeneration = -1; + } } private void PublishEmbeddedSurface(string recordKey, EveSurfaceDocument surface) @@ -948,13 +988,18 @@ namespace GameCult.Eve.UnityScene PublishComposedSurface(); } - private void PublishComposedSurface() + private void PublishComposedSurface(long? committedBaseVersion = null) { - var surface = ComposeSurface( - _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable."), - _embeddedSurfaces); + ApplyPreparedSurface(PrepareComposedSurface( + _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable.")), + committedBaseVersion); + } + + private PreparedSurface PrepareComposedSurface(EveSurfaceDocument baseSurface) + { + var surface = ComposeSurface(baseSurface, _embeddedSurfaces); var interaction = RequireWorldInteraction(); - CurrentSurfaceDocument = new EveUnitySceneProviderSurfaceDocument( + var document = new EveUnitySceneProviderSurfaceDocument( surface, new EveUnitySceneProviderSurfaceAdvertisement( _advertisedSurface.SurfaceId, @@ -966,7 +1011,25 @@ namespace GameCult.Eve.UnityScene interaction.Ownership)), _advertisedSurface.RecordRef, surface.Version); + var lowered = new EveUnitySceneSurfaceLowerer() + .Lower(document.SurfaceDocument, document.AdvertisedSurface); + var continuousCommands = new HashSet(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.MovementCommand)) + continuousCommands.Add(lowered.PlayableWorld.MovementCommand); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.LookCommand)) + continuousCommands.Add(lowered.PlayableWorld.LookCommand); + return new PreparedSurface(document, continuousCommands); + } + + private void ApplyPreparedSurface(PreparedSurface prepared, long? committedBaseVersion = null) + { + CurrentSurfaceDocument = prepared.Document; + _continuousCommandIds.Clear(); + foreach (var command in prepared.ContinuousCommands) + _continuousCommandIds.Add(command); SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); + if (committedBaseVersion.HasValue) + CompletePresentationTransition(committedBaseVersion.Value); } internal static EveSurfaceDocument ComposeSurface( @@ -1013,37 +1076,24 @@ namespace GameCult.Eve.UnityScene component.Style); } - private void EnsureLiveSubscriptions() + private async Task EnsureLiveDocumentsAsync(CancellationToken cancellationToken) { - if (_subscriptions != null && _entitySubscriptions != null) return; - _entityBody?.Dispose(); - _entityBody = null; - _inputCapability?.Dispose(); - _inputCapability = null; - _subscriptions?.Dispose(); - _entitySubscriptions?.Dispose(); - _subscriptions = null; - _entitySubscriptions = null; + if (_liveDocumentsReady) return; + DisposeLiveDocuments(); try { - _subscriptions = CreateSubscriptionClient(); - _entitySubscriptions = CreateSubscriptionClient(); + await OpenLiveDocumentsAsync(cancellationToken); + _liveDocumentsReady = true; } catch { - _entityBody?.Dispose(); - _entityBody = null; - _inputCapability?.Dispose(); - _inputCapability = null; - _subscriptions?.Dispose(); - _entitySubscriptions?.Dispose(); - _subscriptions = null; - _entitySubscriptions = null; + DisposeLiveDocuments(); throw; } - _subscriptions.Changed += OnReplicatedDocumentChanged; - _entitySubscriptions.Changed += OnReplicatedDocumentChanged; + } + private async Task OpenLiveDocumentsAsync(CancellationToken cancellationToken) + { var lowered = new EveUnitySceneSurfaceLowerer() .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); string entityViewPointer = lowered.PlayableWorld == null @@ -1065,140 +1115,176 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException( "The playable world advertises an entity-view pointer without its logical body id."); _bodyResolver ??= CreateBodyResolver(); - var initialLayout = _entitySubscriptions.SubscribeAsync( - "eve-unity-entity-layout", - recordKeys: new[] { entityViewPointer }, - schemaIds: new[] { EveEntitySoaViewDocument.SchemaId }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult(); - foreach (var layout in initialLayout.OfType()) - QueueEntityView(layout); - - _entityBody = CultMesh.SubscribeLiveBodyAsync( - _entitySubscriptions, - _bodyResolver, - new CultMeshLiveBodySubscription( - "eve-unity-entity-body", - _runtimeId, - entityBodyId)) - .GetAwaiter().GetResult(); - if (_entityBody.HasValue) - QueueBodyPublication(_entityBody.Current); - _entityBody.Changed += QueueBodyPublication; - _entityBody.Removed += () => _liveDocuments.Enqueue(new InvalidOperationException( - $"The provider withdrew required entity body '{entityBodyId}'.")); - - if (fieldRefs.Length > 0) + await LeaseDocumentAsync(entityViewPointer, QueueEntityView, cancellationToken); + await LeaseCollectionAsync(publication => { - var initialFields = _entitySubscriptions.SubscribeAsync( - "eve-unity-fields", - recordKeys: fieldRefs, - schemaIds: new[] { EveFieldsSchemas.Splats }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult(); - foreach (var fields in initialFields.OfType()) - _liveDocuments.Enqueue(fields); - } + if (string.Equals(publication.BodyId, entityBodyId, StringComparison.Ordinal)) + QueueBodyPublication(publication); + }, cancellationToken); } - else if (fieldRefs.Length > 0) - { - var initial = _entitySubscriptions.SubscribeAsync( - "eve-unity-fields", - recordKeys: fieldRefs, - schemaIds: new[] { EveFieldsSchemas.Splats }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult(); - foreach (var fields in initial.OfType()) - _liveDocuments.Enqueue(fields); - } - _subscriptions.SubscribeAsync( - "eve-unity-surface", - recordKeys: new[] { _advertisedSurface!.RecordRef }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }, - includeSnapshot: false) - .GetAwaiter().GetResult(); + foreach (var fieldRef in fieldRefs) + await LeaseDocumentAsync( + fieldRef, + fields => _liveDocuments.Enqueue(fields), + cancellationToken); + + await LeaseDocumentAsync( + _advertisedSurface!.RecordRef, + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(_advertisedSurface.RecordRef, surface)), + cancellationToken, + publishInitial: false); var embeddedSlots = EnumerateEmbeddedDocuments(CurrentSurfaceDocument.SurfaceDocument.Surface.Root) .Where(slot => string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal)) .Where(slot => !string.IsNullOrWhiteSpace(slot.DocumentId)) .GroupBy(slot => slot.DocumentId, StringComparer.Ordinal) .Select(group => group.First()) .ToArray(); - for (var index = 0; index < embeddedSlots.Length; index++) + foreach (var slot in embeddedSlots) { - var slot = embeddedSlots[index]; - var initialEmbedded = _subscriptions.SubscribeAsync( - $"eve-unity-embedded-surface-{index}", - recordKeys: new[] { slot.DocumentId }, - schemaIds: new[] { EveSurfaceDocument.SchemaId }, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult() - .OfType() - .FirstOrDefault(); - if (initialEmbedded != null) - PublishEmbeddedSurface(slot.DocumentId, initialEmbedded); + await LeaseDocumentAsync( + slot.DocumentId, + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface)), + cancellationToken); } var inputCapabilityRef = FindComponentProp( CurrentSurfaceDocument.SurfaceDocument.Surface.Root, "inputCapability"); if (!string.IsNullOrWhiteSpace(inputCapabilityRef)) { - _inputCapability = _subscriptions - .SubscribeLiveValueAsync( - "eve-unity-input-capability", - inputCapabilityRef) - .GetAwaiter().GetResult(); - if (_inputCapability.HasValue) - CurrentInputCapability = _inputCapability.Current; - _inputCapability.Changed += document => _liveDocuments.Enqueue(document); - _inputCapability.Removed += () => _liveDocuments.Enqueue(new InvalidOperationException( - $"The provider withdrew required input capability '{inputCapabilityRef}'.")); + await LeaseDocumentAsync( + inputCapabilityRef, + document => _liveDocuments.Enqueue(document), + cancellationToken); } - var assetCatalogRef = RequireWorldInteraction().AssetManifestRecordRef; - if (!string.IsNullOrWhiteSpace(assetCatalogRef)) - { - _subscriptions.SubscribeAsync( - "eve-unity-asset-catalog", - recordKeys: new[] { assetCatalogRef }, - schemaIds: new[] { EveAssetCatalogDocument.SchemaId }, - includeSnapshot: false) - .GetAwaiter().GetResult(); - } - _subscriptions.SubscribeAsync( - "eve-unity-receipts", - schemaIds: new[] { EveCommandReceiptDocument.SchemaId }, - includeSnapshot: false, - deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live) - .GetAwaiter().GetResult(); + 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 CultNetDatabaseSubscriptionClient CreateSubscriptionClient() + private Task EnsureReceiptSubscriptionAsync( + string commandId, + CancellationToken cancellationToken) { - var endpoint = new Uri(_endpoint); - var client = CultNetSchemaClients.CreateForEndpoint(_endpoint); - client.Connect(endpoint.Host, endpoint.Port); - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); - while (!client.Connected && DateTime.UtcNow < deadline) - System.Threading.Thread.Sleep(1); - if (!client.Connected) + Task subscription; + lock (_pendingCommands) { - client.Dispose(); - throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'."); + if (_receiptSubscriptions.TryGetValue(commandId, out subscription!)) return subscription; + subscription = OpenReceiptSubscriptionAsync(commandId, cancellationToken); + _receiptSubscriptions.Add(commandId, subscription); } - return new CultNetDatabaseSubscriptionClient(client, _node!.Database.Cache, _networkRegistry!); + return RemoveFailedReceiptSubscriptionAsync(commandId, subscription); } - private void OnReplicatedDocumentChanged(CultNetReplicatedDocumentChange change) + private async Task RemoveFailedReceiptSubscriptionAsync( + string commandId, + Task subscription) { - TraceHotState($"change subscription={change.SubscriptionId} kind={change.ChangeKind} document={change.Document?.GetType().Name ?? "null"}"); - if (change.Document is EveEntitySoaViewDocument entityView) - QueueEntityView(entityView); - else if (change.Document is CultMeshBodyPublicationDocument publication) - QueueBodyPublication(publication); - else if (change.Document is EveSurfaceDocument surface) - _liveDocuments.Enqueue(new PendingSurfaceDocument(change.RecordKey, surface)); - else if (change.Document is EveAssetCatalogDocument or - EveCommandReceiptDocument or EveFieldsSplatsDocument) - _liveDocuments.Enqueue(change.Document); + try { return await subscription.ConfigureAwait(false); } + catch + { + lock (_pendingCommands) + { + if (_receiptSubscriptions.TryGetValue(commandId, out var current) && + ReferenceEquals(current, subscription)) + _receiptSubscriptions.Remove(commandId); + } + throw; + } + } + + private async Task OpenReceiptSubscriptionAsync( + string commandId, + CancellationToken cancellationToken) + { + var recordKey = ChildRecordKey(RequireWorldInteraction().ReceiptRecordRef, commandId); + var lease = await _meshClient!.LeaseDocumentAsync( + _target, + recordKey, + cancellationToken) + .ConfigureAwait(false); + IDisposable? watch = null; + try + { + watch = lease.Handle.Watch(receipt => QueueReceipt(commandId, receipt)); + try { QueueReceipt(commandId, await lease.Handle.LatestAsync().ConfigureAwait(false)); } + catch (KeyNotFoundException) { } + return new ReceiptSubscription(lease, watch); + } + catch + { + watch?.Dispose(); + lease.Dispose(); + throw; + } + } + + private void QueueReceipt(string expectedCommandId, EveCommandReceiptDocument receipt) + { + if (receipt == null) return; + lock (_pendingCommands) + { + if (!_pendingCommands.TryGetValue(expectedCommandId, out var request) || + !ReceiptMatches(request, receipt)) + return; + } + _liveDocuments.Enqueue(receipt); + } + + private static void DisposeReceiptSubscription(Task subscription) + { + if (subscription.Status == TaskStatus.RanToCompletion) + subscription.Result.Dispose(); + else + _ = subscription.ContinueWith( + completed => { if (completed.Status == TaskStatus.RanToCompletion) completed.Result.Dispose(); }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private void DisposeLiveDocuments() + { + foreach (var watch in _documentWatches) watch.Dispose(); + _documentWatches.Clear(); + foreach (var lease in _documentLeases) lease.Dispose(); + _documentLeases.Clear(); + _liveDocumentsReady = false; + } + + private async Task LeaseDocumentAsync( + string recordKey, + Action publish, + CancellationToken cancellationToken, + bool publishInitial = true) + where TDocument : class + { + var lease = await _meshClient!.LeaseDocumentAsync(_target, recordKey, cancellationToken); + _documentLeases.Add(lease); + if (publishInitial) publish(await lease.Handle.LatestAsync()); + _documentWatches.Add(lease.Handle.Watch(publish)); + } + + private async Task LeaseCollectionAsync( + Action publish, + CancellationToken cancellationToken, + bool includeInitialSnapshot = true) + where TDocument : class + { + var lease = await _meshClient!.LeaseCollectionAsync( + _target, + includeInitialSnapshot, + cancellationToken); + _documentLeases.Add(lease); + if (includeInitialSnapshot) + foreach (var document in await lease.Handle.LatestAsync()) + publish(document); + _documentWatches.Add(lease.Handle.WatchChanges(change => + { + if (change.Document != null) publish(change.Document); + })); } private static IEnumerable EnumerateEmbeddedDocuments(EveSurfaceComponent component) @@ -1222,48 +1308,341 @@ namespace GameCult.Eve.UnityScene public EveSurfaceDocument Document { get; } } + private sealed class PreparedSurface + { + public PreparedSurface( + EveUnitySceneProviderSurfaceDocument document, + IReadOnlyCollection continuousCommands) + { + Document = document; + ContinuousCommands = continuousCommands; + } + + public EveUnitySceneProviderSurfaceDocument Document { get; } + public IReadOnlyCollection ContinuousCommands { get; } + } + private static void TraceHotState(string message) { if (string.Equals(Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", StringComparison.Ordinal)) Debug.Log($"EveUnity CultMesh hot state: {message}"); } - private void RefreshAssetCatalog() + private async Task RefreshAssetCatalogAsync(CancellationToken cancellationToken) { - var interaction = RequireWorldInteraction(); - if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) + var source = ResolveAssetSource( + _baseSurface ?? throw new InvalidOperationException("The provider surface must be available before its assets are resolved.")); + if (string.IsNullOrWhiteSpace(source.ManifestRecordRef)) return; - var catalog = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { interaction.AssetManifestRecordRef }, - schemaIds: new[] { EveAssetCatalogDocument.SchemaId }) - .GetAwaiter() - .GetResult() - .FirstOrDefault(); - if (catalog != null) - PublishAssetCatalog(catalog); + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); + CommitAssetGeneration(candidate); } - private void PublishAssetCatalog(EveAssetCatalogDocument catalog) + private async Task RefreshAssetsForSurfaceAsync( + EveSurfaceDocument surface, + AssetSource source, + long generation, + CancellationToken cancellationToken) { - if (catalog.Version == CurrentAssetCatalogVersion) - return; + Exception? finalError = null; + Interlocked.Exchange(ref _lastAssetPresentationAttemptCount, 0); + for (var attempt = 1; attempt <= AssetPresentationAttemptLimit; attempt++) + { + Interlocked.Exchange(ref _lastAssetPresentationAttemptCount, attempt); + try + { + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + await _assetGenerationGate.WaitAsync(cancellationToken); + try + { + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + { + candidate.Dispose(); + return; + } + CommitAssetGeneration(candidate, surface); + return; + } + finally + { + _assetGenerationGate.Release(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + finalError = error; + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + if (attempt < AssetPresentationAttemptLimit) + { + try + { + await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + } + } + } + RetireFailedBaseSurfaceCandidate(surface.Version, generation); + _liveDocuments.Enqueue(finalError ?? new InvalidOperationException( + $"Eve surface {surface.Version} asset preparation exhausted its retry policy.")); + } - foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); - _assetBundles.Clear(); - foreach (var lease in _assetBodyLeases) lease.Dispose(); - _assetBodyLeases.Clear(); - CurrentAssetBodyTransportKind = null; - _prefabs.Clear(); - _nativeAssets.Clear(); - _nativeAssetMetadata.Clear(); - _assetSelections.Clear(); - _bundleSelections.Clear(); - _bundleVariants.Clear(); - _loadedBundleUris.Clear(); - _loadingBundleUris.Clear(); - _renderChannelLayers.Clear(); + private AssetSource ResolveAssetSource(EveSurfaceDocument surface) + { + var world = FindWorldScene(surface.Surface.Root); + var manifestRecordRef = world?.GetProp("assetManifest") ?? ""; + if (string.IsNullOrWhiteSpace(manifestRecordRef)) + manifestRecordRef = RequireWorldInteraction().AssetManifestRecordRef; + var providerId = world?.GetProp("assetProviderId") ?? ""; + var verseId = world?.GetProp("assetVerseId") ?? ""; + var authorityRuntimeId = world?.GetProp("assetAuthorityRuntimeId") ?? ""; + var requiredCatalogVersion = long.TryParse( + world?.GetProp("assetCatalogVersion"), + out var parsedCatalogVersion) + ? Math.Max(0, parsedCatalogVersion) + : 0; + var rendezvousEndpoints = ParseEndpointList(world?.GetProp("assetRendezvousEndpoints") ?? ""); + var hasQualifiedSource = !string.IsNullOrWhiteSpace(providerId) || + !string.IsNullOrWhiteSpace(verseId) || + !string.IsNullOrWhiteSpace(authorityRuntimeId); + if (!hasQualifiedSource) + return new AssetSource( + _target, + _providerId, + manifestRecordRef, + Array.Empty(), + requiredCatalogVersion); + if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(verseId) || + string.IsNullOrWhiteSpace(authorityRuntimeId)) + throw new InvalidOperationException( + "A provider-qualified Eve asset source requires assetProviderId, assetVerseId, and assetAuthorityRuntimeId."); + return new AssetSource( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + providerId, + manifestRecordRef, + rendezvousEndpoints, + requiredCatalogVersion); + } + + private async Task BuildAssetGenerationAsync( + AssetSource source, + CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var crossTarget = IsCrossTarget(source); + var endpoints = AssetRendezvousEndpoints(source, crossTarget); + var candidate = new AssetGeneration( + source, + source.Identity, + catalogVersion: -1, + crossTarget || source.RendezvousEndpoints.Count > 0 + ? CreateMeshClient( + endpoints, + AssetAuthorityTrust(source)) + : null); + try + { + var catalog = await AttachAssetCatalogSubscriptionAsync( + candidate, + source.RequiredCatalogVersion, + cancellationToken); + ConfigureAssetCatalog(candidate, catalog); + ReuseCompatibleBundles(_assetGeneration, candidate); + await PreloadAssetsAsync(candidate, cancellationToken); + if (source.RequiredCatalogVersion > 0) + return candidate; + var latest = await candidate.CatalogLease!.Handle.LatestAsync(); + candidate.ObserveCatalog(latest); + if (latest.Version != candidate.CatalogVersion) + { + candidate.Dispose(); + continue; + } + return candidate; + } + catch + { + candidate.Dispose(); + throw; + } + } + } + + private bool IsCrossTarget(AssetSource source) => + !string.Equals(source.Target.VerseId, _target.VerseId, StringComparison.Ordinal) || + !string.Equals(source.Target.AuthorityRuntimeId, _target.AuthorityRuntimeId, StringComparison.Ordinal); + + private CultMeshAuthorityTrustPolicy AssetAuthorityTrust(AssetSource source) => + IsCrossTarget(source) ? _crossTargetAuthorityTrust : _authorityTrust; + + private IReadOnlyList AssetRendezvousEndpoints(AssetSource source, bool crossTarget) + { + if (source.RendezvousEndpoints.Count > 0) + return source.RendezvousEndpoints; + if (crossTarget) + throw new InvalidOperationException( + $"Cross-target asset source '{source.Target.VerseId}/{source.Target.AuthorityRuntimeId}' " + + "must advertise at least one Odin rendezvous endpoint."); + return new[] { _rendezvousEndpoint }; + } + + private async Task AttachAssetCatalogSubscriptionAsync( + AssetGeneration candidate, + long requiredCatalogVersion, + CancellationToken cancellationToken) + { + var lease = await AssetMeshClient(candidate) + .LeaseDocumentAsync( + candidate.Source.Target, + candidate.Source.ManifestRecordRef, + cancellationToken); + IDisposable? watch = null; + try + { + var catalog = await lease.Handle.LatestAsync(); + candidate.ObserveCatalog(catalog); + if (requiredCatalogVersion > 0) + { + RequireCatalogVersion(catalog, requiredCatalogVersion, candidate.Source.ManifestRecordRef); + candidate.CatalogLease = lease; + return catalog; + } + + watch = lease.Handle.Watch(observedCatalog => + { + var pending = candidate.ObserveCatalog(observedCatalog); + if (pending != null) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pending); + }); + candidate.CatalogWatch = watch; + candidate.CatalogLease = lease; + return catalog; + } + catch + { + watch?.Dispose(); + lease.Dispose(); + throw; + } + } + + private static void RequireCatalogVersion( + EveAssetCatalogDocument catalog, + long requiredCatalogVersion, + string manifestRecordRef) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + if (requiredCatalogVersion <= 0 || catalog.Version == requiredCatalogVersion) + return; + throw new InvalidDataException( + $"Asset catalog record '{manifestRecordRef}' contains version {catalog.Version}, " + + $"but the Eve surface requires immutable version {requiredCatalogVersion}."); + } + + private void CommitAssetGeneration(AssetGeneration candidate, EveSurfaceDocument? surface = null) + { + PreparedSurface prepared; + try + { + prepared = PrepareComposedSurface(surface ?? _baseSurface ?? + throw new InvalidOperationException("The base Eve surface is unavailable.")); + } + catch + { + candidate.Dispose(); + throw; + } + var previous = _assetGeneration; + previous.TransferSharedBundlesTo(candidate); + _assetGeneration = candidate; + var pendingCatalog = candidate.ActivateCatalogObservation(); + if (pendingCatalog != null && candidate.Source.RequiredCatalogVersion <= 0) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pendingCatalog); + if (surface != null) + _baseSurface = surface; + try + { + ApplyPreparedSurface(prepared, surface?.Version); + } + finally + { + previous.Dispose(); + } + } + + private static void ReuseCompatibleBundles(AssetGeneration current, AssetGeneration candidate) + { + foreach (var pair in candidate.BundleVariants) + { + if (!current.LoadedBundlesByUri.TryGetValue(pair.Key, out var bundle) || + !current.BundleVariants.TryGetValue(pair.Key, out var existing) || + !string.Equals(existing.ContentHash, pair.Value.ContentHash, StringComparison.Ordinal) || + existing.SizeBytes != pair.Value.SizeBytes) + continue; + var selections = candidate.BundleSelections[pair.Key]; + if (selections.Any(selection => + !current.NativeAssets.ContainsKey(selection.Asset.AssetRef))) + continue; + + candidate.AssetBundles.Add(bundle); + candidate.BorrowedAssetBundles.Add(bundle); + candidate.LoadedBundlesByUri[pair.Key] = bundle; + candidate.LoadedBundleUris.Add(pair.Key); + foreach (var selection in selections) + { + var value = current.NativeAssets[selection.Asset.AssetRef]; + candidate.NativeAssets[selection.Asset.AssetRef] = value; + if (value is GameObject prefab) + candidate.Prefabs[selection.Asset.AssetRef] = prefab; + if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && + !string.IsNullOrWhiteSpace(role)) + { + candidate.NativeAssets[role] = value; + if (value is GameObject rolePrefab) + candidate.Prefabs[role] = rolePrefab; + } + } + } + } + + private static EveSurfaceComponent? FindWorldScene(EveSurfaceComponent component) + { + if (string.Equals(component.Kind, "world.scene3d", StringComparison.Ordinal) || + string.Equals(component.Kind, "world", StringComparison.Ordinal)) + return component; + foreach (var child in component.Children ?? Array.Empty()) + { + var found = FindWorldScene(child); + if (found != null) return found; + } + return null; + } + + private static IReadOnlyList ParseEndpointList(string value) => + (value ?? "") + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(endpoint => endpoint.Trim()) + .Where(endpoint => endpoint.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private static void ConfigureAssetCatalog(AssetGeneration candidate, EveAssetCatalogDocument catalog) + { var selected = catalog.Assets .Select(asset => new SelectedAsset( asset, @@ -1282,101 +1661,178 @@ namespace GameCult.Eve.UnityScene $"Provider asset catalog '{catalog.CatalogId}' has no unity-scene/{CurrentBundlePlatform()} variant. " + $"Advertised variants: {string.Join(", ", advertisedVariants)}"); } - ReadCameraPolicies(selected.Select(selection => selection.Variant!)); + candidate.SetCatalogVersion(catalog.Version); + ReadCameraPolicies(candidate, selected.Select(selection => selection.Variant!)); foreach (var selection in selected) { var variant = selection.Variant!; - _assetSelections[selection.Asset.AssetRef] = selection; - _bundleVariants[variant.Uri] = variant; - if (!_bundleSelections.TryGetValue(variant.Uri, out var group)) + candidate.AssetSelections[selection.Asset.AssetRef] = selection; + candidate.BundleVariants[variant.Uri] = variant; + if (!candidate.BundleSelections.TryGetValue(variant.Uri, out var group)) { group = new List(); - _bundleSelections[variant.Uri] = group; + candidate.BundleSelections[variant.Uri] = group; } group.Add(selection); var metadata = MergeAssetMetadata(selection.Asset.Metadata, variant.Metadata); - _nativeAssetMetadata[selection.Asset.AssetRef] = metadata; + candidate.NativeAssetMetadata[selection.Asset.AssetRef] = metadata; if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && !string.IsNullOrWhiteSpace(role)) { - _assetSelections[role] = selection; - _nativeAssetMetadata[role] = metadata; + candidate.AssetSelections[role] = selection; + candidate.NativeAssetMetadata[role] = metadata; } } - - CurrentAssetCatalogVersion = catalog.Version; } - private void EnsureAssetLoaded(string assetRef) + private void QueueAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog) { - if (_nativeAssets.ContainsKey(assetRef) || !_assetSelections.TryGetValue(assetRef, out var selection)) + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + if (_assetGeneration.Source.RequiredCatalogVersion > 0) + { + _pendingAssetCatalog = null; return; - EnsureBundleLoaded(selection.Variant!.Uri); + } + _pendingAssetCatalog = new PendingAssetCatalogUpdate( + sourceIdentity, + catalog); + if (_assetCatalogUpdateRunning) return; + _assetCatalogUpdateRunning = true; + _ = DrainAssetCatalogUpdatesAsync(); } - private void EnsureBundleLoaded(string uri) + private async Task DrainAssetCatalogUpdatesAsync() { - if (_loadedBundleUris.Contains(uri)) return; - if (!_bundleVariants.TryGetValue(uri, out var variant)) + try + { + while (!_lifetime.IsCancellationRequested) + { + var update = _pendingAssetCatalog; + _pendingAssetCatalog = null; + if (update == null) break; + if (_assetGeneration.Source.RequiredCatalogVersion > 0) + continue; + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal)) + continue; + if (update.Catalog.Version == CurrentAssetCatalogVersion) + continue; + await _assetGenerationGate.WaitAsync(_lifetime.Token); + try + { + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) || + update.Catalog.Version == CurrentAssetCatalogVersion || + _assetGeneration.Source.RequiredCatalogVersion > 0) + continue; + var currentSource = _assetGeneration.Source; + var source = new AssetSource( + currentSource.Target, + currentSource.ProviderId, + currentSource.ManifestRecordRef, + currentSource.RendezvousEndpoints, + 0); + var candidate = await BuildAssetGenerationAsync(source, _lifetime.Token); + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) || + _assetGeneration.Source.RequiredCatalogVersion > 0) + { + candidate.Dispose(); + continue; + } + CommitAssetGeneration(candidate); + } + finally + { + _assetGenerationGate.Release(); + } + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (Exception error) + { + _liveDocuments.Enqueue(error); + } + finally + { + _assetCatalogUpdateRunning = false; + if (_pendingAssetCatalog != null && !_lifetime.IsCancellationRequested) + QueueAssetCatalogUpdate(_pendingAssetCatalog.SourceIdentity, _pendingAssetCatalog.Catalog); + } + } + + private async Task PreloadAssetsAsync(AssetGeneration candidate, CancellationToken cancellationToken) + { + foreach (var uri in candidate.BundleVariants.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray()) + await EnsureBundleLoadedAsync(candidate, uri, cancellationToken); + } + + private async Task EnsureBundleLoadedAsync( + AssetGeneration candidate, + string uri, + CancellationToken cancellationToken) + { + if (candidate.LoadedBundleUris.Contains(uri)) return; + if (!candidate.BundleVariants.TryGetValue(uri, out var variant)) throw new InvalidOperationException($"Provider asset bundle '{uri}' is not present in the selected runtime catalog."); - if (!_loadingBundleUris.Add(uri)) + if (!candidate.LoadingBundleUris.Add(uri)) throw new InvalidOperationException($"Provider asset bundle dependency cycle includes '{uri}'."); try { if (variant.Metadata.TryGetValue("unity.bundleDependencyUris", out var dependencies)) foreach (var dependency in dependencies.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) - EnsureBundleLoaded(dependency); + await EnsureBundleLoadedAsync(candidate, dependency, cancellationToken); var groupElapsed = Stopwatch.StartNew(); - var descriptor = _snapshot!.FetchDocumentsAsync( - recordKeys: new[] { uri }, - schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactManifest }) - .GetAwaiter() - .GetResult() - .FirstOrDefault(); - if (descriptor == null) - throw new InvalidOperationException($"Provider asset bundle manifest '{uri}' was not available."); + var descriptor = await AssetMeshClient(candidate) + .ReadAsync(candidate.Source.Target, uri, cancellationToken); TraceStartup("asset-manifest", groupElapsed); - var bundle = LoadVerifiedBundle(descriptor, variant); + var bundle = await LoadVerifiedBundleAsync(candidate, descriptor, variant, cancellationToken); TraceStartup("asset-transfer-and-bundle-load", groupElapsed); if (bundle == null) throw new InvalidOperationException($"Unity could not load provider asset bundle '{uri}'."); - _assetBundles.Add(bundle); + candidate.AssetBundles.Add(bundle); + candidate.LoadedBundlesByUri[uri] = bundle; var bundleAssetNames = bundle.GetAllAssetNames(); - foreach (var selection in _bundleSelections[uri]) + foreach (var selection in candidate.BundleSelections[uri]) { var bundleAssetName = ResolveBundleAssetName(bundleAssetNames, selection.Variant!.AssetKey); var value = bundleAssetName == null ? null - : LoadBundleAsset(bundle, bundleAssetName, selection.Asset.AssetKind); + : await LoadBundleAssetAsync(bundle, bundleAssetName, selection.Asset.AssetKind, cancellationToken); if (value == null) throw new InvalidOperationException( $"Provider asset '{selection.Asset.AssetRef}' advertises missing Unity bundle asset " + $"'{selection.Variant.AssetKey}'. Available assets: " + string.Join(", ", bundleAssetNames)); - _nativeAssets[selection.Asset.AssetRef] = value; + candidate.NativeAssets[selection.Asset.AssetRef] = value; if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && !string.IsNullOrWhiteSpace(role)) - _nativeAssets[role] = value; + candidate.NativeAssets[role] = value; if (value is GameObject prefab) { - _prefabs[selection.Asset.AssetRef] = prefab; - if (!string.IsNullOrWhiteSpace(role)) _prefabs[role] = prefab; + candidate.Prefabs[selection.Asset.AssetRef] = prefab; + if (!string.IsNullOrWhiteSpace(role)) candidate.Prefabs[role] = prefab; } } - _loadedBundleUris.Add(uri); + candidate.LoadedBundleUris.Add(uri); TraceStartup("bundle-assets", groupElapsed); } finally { - _loadingBundleUris.Remove(uri); + candidate.LoadingBundleUris.Remove(uri); } } - private static UnityEngine.Object? LoadBundleAsset(AssetBundle bundle, string assetName, string assetKind) + private static async Task LoadBundleAssetAsync( + AssetBundle bundle, + string assetName, + string assetKind, + CancellationToken cancellationToken) { - return string.Equals(assetKind, "sprite", StringComparison.Ordinal) - ? bundle.LoadAsset(assetName) - : bundle.LoadAsset(assetName); + var request = string.Equals(assetKind, "sprite", StringComparison.Ordinal) + ? bundle.LoadAssetAsync(assetName) + : bundle.LoadAssetAsync(assetName); + await AwaitUnityAsync(request, cancellationToken); + return request.asset; } private sealed class SelectedAsset @@ -1391,6 +1847,115 @@ namespace GameCult.Eve.UnityScene public EveAssetVariant? Variant { get; } } + private sealed class AssetGeneration : IDisposable + { + private readonly object _catalogObservationGate = new object(); + private EveAssetCatalogDocument? _latestObservedCatalog; + private bool _catalogObservationActive; + + public AssetGeneration( + AssetSource source, + string sourceIdentity, + long catalogVersion, + CultMeshClient? meshClient) + { + Source = source; + SourceIdentity = sourceIdentity ?? ""; + CatalogVersion = catalogVersion; + MeshClient = meshClient; + } + + public AssetSource Source { get; } + public string SourceIdentity { get; } + public long CatalogVersion { get; private set; } + public CultMeshClient? MeshClient { get; } + public CultMeshDocumentLease? CatalogLease { get; set; } + public IDisposable? CatalogWatch { get; set; } + public CultMeshContentTransferService? ContentTransfer { get; set; } + public CultMeshVerifiedBodyMappingBroker? BodyMappings { get; set; } + public CultMeshBodyTransportKind? BodyTransportKind { get; set; } + public Dictionary Prefabs { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary NativeAssets { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary> NativeAssetMetadata { get; } = + new Dictionary>(StringComparer.Ordinal); + public List AssetBundles { get; } = new List(); + public HashSet BorrowedAssetBundles { get; } = new HashSet(); + public Dictionary LoadedBundlesByUri { get; } = + new Dictionary(StringComparer.Ordinal); + public Dictionary AssetSelections { get; } = + new Dictionary(StringComparer.Ordinal); + public Dictionary> BundleSelections { get; } = + new Dictionary>(StringComparer.Ordinal); + public Dictionary BundleVariants { get; } = + new Dictionary(StringComparer.Ordinal); + public HashSet LoadedBundleUris { get; } = new HashSet(StringComparer.Ordinal); + public HashSet LoadingBundleUris { get; } = new HashSet(StringComparer.Ordinal); + public List BodyLeases { get; } = new List(); + public Dictionary RenderChannelLayers { get; } = + new Dictionary(StringComparer.Ordinal); + + public void TransferSharedBundlesTo(AssetGeneration next) + { + foreach (var bundle in next.BorrowedAssetBundles) + AssetBundles.Remove(bundle); + next.BorrowedAssetBundles.Clear(); + } + + public void SetCatalogVersion(long catalogVersion) + { + if (CatalogVersion >= 0 && CatalogVersion != catalogVersion) + throw new InvalidOperationException("An asset generation cannot change its catalog version after configuration."); + CatalogVersion = catalogVersion; + } + + public EveAssetCatalogDocument? ObserveCatalog(EveAssetCatalogDocument catalog) + { + lock (_catalogObservationGate) + { + _latestObservedCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + return _catalogObservationActive && catalog.Version != CatalogVersion ? catalog : null; + } + } + + public EveAssetCatalogDocument? ActivateCatalogObservation() + { + lock (_catalogObservationGate) + { + _catalogObservationActive = true; + return _latestObservedCatalog != null && _latestObservedCatalog.Version != CatalogVersion + ? _latestObservedCatalog + : null; + } + } + + public void Dispose() + { + CatalogWatch?.Dispose(); + CatalogWatch = null; + CatalogLease?.Dispose(); + CatalogLease = null; + MeshClient?.Dispose(); + foreach (var bundle in AssetBundles) + if (!BorrowedAssetBundles.Contains(bundle)) + bundle.Unload(unloadAllLoadedObjects: false); + AssetBundles.Clear(); + BorrowedAssetBundles.Clear(); + foreach (var lease in BodyLeases) + lease.Dispose(); + BodyLeases.Clear(); + Prefabs.Clear(); + NativeAssets.Clear(); + NativeAssetMetadata.Clear(); + AssetSelections.Clear(); + BundleSelections.Clear(); + BundleVariants.Clear(); + LoadedBundleUris.Clear(); + LoadingBundleUris.Clear(); + LoadedBundlesByUri.Clear(); + RenderChannelLayers.Clear(); + } + } + private static string? ResolveBundleAssetName(IEnumerable bundleAssetNames, string advertisedAssetKey) { return bundleAssetNames.FirstOrDefault(assetName => @@ -1407,9 +1972,8 @@ namespace GameCult.Eve.UnityScene return merged; } - private void ReadCameraPolicies(IEnumerable variants) + private static void ReadCameraPolicies(AssetGeneration candidate, IEnumerable variants) { - _renderChannelLayers.Clear(); foreach (var variant in variants) { foreach (var pair in variant.Metadata) @@ -1422,12 +1986,14 @@ namespace GameCult.Eve.UnityScene var channel = pair.Key.Substring(prefix.Length, pair.Key.Length - prefix.Length - suffix.Length); if (!string.IsNullOrWhiteSpace(channel) && int.TryParse(pair.Value, out var layer) && layer >= 0 && layer < 32) - _renderChannelLayers[channel] = layer; + candidate.RenderChannelLayers[channel] = layer; } } } - private long CurrentAssetCatalogVersion { get; set; } = -1; + private long CurrentAssetCatalogVersion => _assetGeneration.CatalogVersion; + + private string CurrentAssetSourceIdentity => _assetGeneration.SourceIdentity; private static string CurrentBundlePlatform() { @@ -1437,31 +2003,38 @@ namespace GameCult.Eve.UnityScene : Application.platform.ToString(); } - private CultMeshContentTransferService ContentTransfer() + private CultMeshContentTransferService ContentTransfer(AssetGeneration candidate) { - ResolveAdvertisement(); - if (_contentTransfer != null) - return _contentTransfer; + if (candidate.ContentTransfer != null) + return candidate.ContentTransfer; + if (_advertisement == null) + throw new InvalidOperationException("The Eve provider advertisement must be prepared before content transfer."); if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId)) throw new InvalidOperationException($"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for content transport."); var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH"); if (string.IsNullOrWhiteSpace(cacheRoot)) cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets"); - _assetBodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); - _contentTransfer = new CultMeshContentTransferService( - _node!.Cache, + candidate.BodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); + _contentState ??= new CultCache( + CultMesh.CreateCultCacheDocumentRegistry(typeof(CultMeshContentTransferStateDocument))); + candidate.ContentTransfer = new CultMeshContentTransferService( + _contentState, new[] { - _meshClient!.ContentProvider( - _advertisement.ProviderId, - _advertisement.ServiceId) + AssetMeshClient(candidate).ContentProvider( + candidate.Source.ProviderId, + candidate.Source.Target) }, new CultMeshContentTransferOptions(cacheRoot), - _assetBodyMappings); - return _contentTransfer; + candidate.BodyMappings); + return candidate.ContentTransfer; } - private AssetBundle? LoadVerifiedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant) + private async Task LoadVerifiedBundleAsync( + AssetGeneration candidate, + CultMeshCdnArtifactManifest manifest, + EveAssetVariant variant, + CancellationToken cancellationToken) { var hash = NormalizeContentHash(variant.ContentHash); if (manifest.SizeBytes != variant.SizeBytes) @@ -1473,12 +2046,10 @@ namespace GameCult.Eve.UnityScene var now = DateTimeOffset.UtcNow; var network = NetworkArtifactDescriptor(manifest, now, TimeSpan.FromMinutes(5)); var elapsed = Stopwatch.StartNew(); - var mapped = ContentTransfer() - .FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5)) - .GetAwaiter() - .GetResult(); + var mapped = await ContentTransfer(candidate) + .FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5), cancellationToken); TraceStartup("content-materialization", elapsed); - var request = new CultMeshBodyValidationRequest + var validationRequest = new CultMeshBodyValidationRequest { BodyId = network.BodyId, SchemaId = network.SchemaId, @@ -1492,34 +2063,28 @@ namespace GameCult.Eve.UnityScene var transport = new CultMeshBodyTransportService( new ICultMeshBodyTransportAdapter[] { - new CultMeshMappedBodyAdapter(_assetBodyMappings!), - new CultMeshNetworkBodyAdapter(_ => ReadVerifiedArtifactBytes(manifest)) + new CultMeshMappedBodyAdapter(candidate.BodyMappings!), + new CultMeshNetworkBodyAdapter(_ => File.ReadAllBytes(mapped.VerifiedPath)) }, descriptor => string.Equals(descriptor.BodyId, manifest.ArtifactId, StringComparison.Ordinal) && string.Equals(descriptor.SemanticHash, hash, StringComparison.Ordinal)); - var negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, request); - CurrentAssetBodyTransportKind = negotiated.SelectedTransport; - _assetBodyLeases.Add(negotiated.Lease); - if (negotiated.SelectedTransport == CultMeshBodyTransportKind.SharedFileMapping) - { - var bundle = AssetBundle.LoadFromFile(mapped.VerifiedPath); - TraceStartup("asset-bundle-load-from-file", elapsed); - return bundle; - } - - if (negotiated.Lease.Descriptor.ByteSize > int.MaxValue) - throw new InvalidOperationException("Unity cannot lower a network asset body larger than one managed byte array."); - var bytes = new byte[checked((int)negotiated.Lease.Descriptor.ByteSize)]; - negotiated.Lease.CopyTo(0, bytes, 0, bytes.Length); - var loaded = AssetBundle.LoadFromMemory(bytes); - TraceStartup("asset-bundle-load-from-memory", elapsed); - return loaded; + var negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, validationRequest); + candidate.BodyTransportKind = negotiated.SelectedTransport; + candidate.BodyLeases.Add(negotiated.Lease); + var bundleRequest = AssetBundle.LoadFromFileAsync(mapped.VerifiedPath); + await AwaitUnityAsync(bundleRequest, cancellationToken); + TraceStartup("asset-bundle-load-from-file", elapsed); + return bundleRequest.assetBundle; } - private byte[] ReadVerifiedArtifactBytes(CultMeshCdnArtifactManifest manifest) + private static async Task AwaitUnityAsync(AsyncOperation operation, CancellationToken cancellationToken) { - var path = ContentTransfer().FetchAsync(manifest).GetAwaiter().GetResult(); - return File.ReadAllBytes(path); + while (!operation.isDone) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + cancellationToken.ThrowIfCancellationRequested(); } private static CultMeshBodyDescriptor NetworkArtifactDescriptor( @@ -1549,8 +2114,103 @@ namespace GameCult.Eve.UnityScene private void PublishReceipt(EveCommandReceiptDocument receipt) { - if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) - return; + var terminal = IsTerminalReceiptState(receipt.State); + lock (_pendingCommands) + { + if (!_pendingCommands.TryGetValue(receipt.CommandId, out var request) || + !ReceiptMatches(request, receipt)) + return; + } + if (terminal) + { + _commandOutbox?.Acknowledge(receipt.CommandId); + if (DeferTerminalReceiptUntilPresentation(receipt)) + return; + } + FinalizeReceipt(receipt, terminal); + } + + private void EnsurePresentationWritable() + { + lock (_presentationFinalityGate) + { + if (_presentationBarrierVersion > _mountedBaseSurfaceVersion) + throw new InvalidOperationException( + $"The Eve presentation is awaiting provider surface version {_presentationBarrierVersion}; " + + $"mounted base version {_mountedBaseSurfaceVersion} is read-only until that generation commits."); + } + } + + private void BeginPresentationTransition(long sourceVersion) + { + lock (_presentationFinalityGate) + { + if (sourceVersion <= _mountedBaseSurfaceVersion) + return; + _presentationBarrierVersion = Math.Max(_presentationBarrierVersion, sourceVersion); + } + } + + private bool DeferTerminalReceiptUntilPresentation(EveCommandReceiptDocument receipt) + { + if (receipt.PresentationSurfaceVersion <= 0) + return false; + lock (_presentationFinalityGate) + { + if (receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion) + return false; + _presentationBarrierVersion = Math.Max( + _presentationBarrierVersion, + receipt.PresentationSurfaceVersion); + _deferredTerminalReceipts[receipt.CommandId] = receipt; + return true; + } + } + + private void CompletePresentationTransition(long committedVersion) + { + EveCommandReceiptDocument[] ready; + lock (_presentationFinalityGate) + { + _mountedBaseSurfaceVersion = Math.Max(_mountedBaseSurfaceVersion, committedVersion); + if (_activeBaseSurfaceCandidateVersion == committedVersion) + { + _activeBaseSurfaceCandidateVersion = -1; + _activeBaseSurfaceCandidateGeneration = -1; + } + ready = _deferredTerminalReceipts.Values + .Where(receipt => receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion) + .ToArray(); + foreach (var receipt in ready) + _deferredTerminalReceipts.Remove(receipt.CommandId); + if (_presentationBarrierVersion <= _mountedBaseSurfaceVersion) + _presentationBarrierVersion = -1; + } + foreach (var receipt in ready) + FinalizeReceipt(receipt, terminal: true); + } + + private long MountedBaseSurfaceVersion + { + get + { + lock (_presentationFinalityGate) + return _mountedBaseSurfaceVersion; + } + } + + private void FinalizeReceipt(EveCommandReceiptDocument receipt, bool terminal) + { + if (terminal) + { + lock (_pendingCommands) + { + if (!_pendingCommands.ContainsKey(receipt.CommandId)) + return; + _pendingCommands.Remove(receipt.CommandId); + } + ForgetPendingCommand(receipt.CommandId); + } CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, @@ -1563,11 +2223,101 @@ namespace GameCult.Eve.UnityScene receipt.SurfaceId, receipt.Message, DateTimeOffset.TryParse(receipt.IssuedAtUtc, out var issuedAt) ? issuedAt : null, - receipt.SourceVersion)); - if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) - _pendingCommandIds.Remove(receipt.CommandId); + receipt.SourceVersion, + receipt.Navigation == null + ? null + : new EveUnitySceneNavigationTarget( + receipt.Navigation.VerseId, + 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(); + } } 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 a9e263c..3d356f3 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -2,6 +2,8 @@ using System; 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; @@ -9,6 +11,7 @@ using GameCult.Eve.PluginFields; using GameCult.Eve.UnityScene.Fields; using GameCult.Mesh; using UnityEngine; +using UnityEngine.Serialization; #nullable enable @@ -26,19 +29,29 @@ namespace GameCult.Eve.UnityScene IEveUnityCameraRenderPolicySource, IEveUnityNativeAssetProvider, IEveUnityNativeAssetMetadataProvider, - IEveUnityInputCapabilitySource + IEveUnityInputCapabilitySource, + IEveUnityNavigableProvider { [SerializeField] private string rendezvousEndpoint = ""; [SerializeField] private string providerFilter = ""; [SerializeField] private string surfaceFilter = ""; [SerializeField] private string verseFilter = ""; [SerializeField] private string surfaceKind = "interactive-world"; - [SerializeField] private string replicaPath = ""; + [FormerlySerializedAs("replicaPath")] + [SerializeField] private string cacheDirectory = ""; [SerializeField] private string runtimeId = "eve-unity"; + private 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(); @@ -76,28 +89,36 @@ namespace GameCult.Eve.UnityScene public void Configure( string endpoint, - string replicaStorePath = "", + string localCacheDirectory = "", string providerId = "", string surfaceId = "", string verseId = "", string requiredSurfaceKind = "interactive-world", - string clientRuntimeId = "eve-unity") + string clientRuntimeId = "eve-unity", + CultMeshAuthorityTrustPolicy? authorityTrust = null, + CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null) { if (_bridge != null || _preparation != null) throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); rendezvousEndpoint = endpoint ?? ""; - replicaPath = replicaStorePath ?? ""; + cacheDirectory = localCacheDirectory ?? ""; providerFilter = providerId ?? ""; surfaceFilter = surfaceId ?? ""; 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() @@ -105,26 +126,132 @@ namespace GameCult.Eve.UnityScene if (_bridge != null) return Task.CompletedTask; if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted) - _preparation = PrepareTransportAsync(); + { + _preparationLifetime?.Dispose(); + _preparationLifetime = new CancellationTokenSource(); + _preparation = PrepareTransportAsync(_preparationLifetime.Token); + } return _preparation; } public void Disconnect() { - _bridge?.Disconnect(); + if (_connectionLeaseCount <= 0) + return; + _connectionLeaseCount--; + if (_connectionLeaseCount == 0) + _bridge?.Disconnect(); + } + + public async Task NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (string.IsNullOrWhiteSpace(target.VerseId)) + throw new ArgumentException("Eve provider navigation requires a stable Verse identity.", nameof(target)); + if (string.IsNullOrWhiteSpace(target.SurfaceId)) + throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target)); + + var endpoints = (target.RendezvousEndpoints ?? Array.Empty()) + .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint)) + .Select(endpoint => endpoint.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (endpoints.Length == 0 && !string.IsNullOrWhiteSpace(rendezvousEndpoint)) + endpoints = new[] { rendezvousEndpoint }; + if (endpoints.Length == 0) + throw new InvalidOperationException("Eve provider navigation requires at least one Odin rendezvous endpoint."); + + var requiredSurfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) + ? "interactive-world" + : target.SurfaceKind; + var prepared = await PrepareCandidateAsync( + endpoints, + target.ProviderId, + target.SurfaceId, + requiredSurfaceKind, + target.VerseId, + target.AuthorityRuntimeId, + _navigationAuthorityTrust, + _navigationAuthorityTrust, + CancellationToken.None).ConfigureAwait(false); + + StagePrepared(prepared); + rendezvousEndpoint = prepared.Selection.RendezvousEndpoint; + verseFilter = target.VerseId; + providerFilter = target.ProviderId; + surfaceFilter = target.SurfaceId; + surfaceKind = requiredSurfaceKind; + _authorityTrust = _navigationAuthorityTrust; + _preparation = Task.CompletedTask; + } + + public void CommitNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + if (_navigationRouteCommitted) return; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; + AttachPrepared(_transport, _bridge); + _navigationRouteCommitted = true; + } + + public void FinalizeNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + _previousProvider = null; + _navigationRouteCommitted = false; + SafeDispose(previous.Bridge); + SafeDispose(previous.Transport); + } + + public void RollbackNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; + DetachPrepared(_transport, _bridge); + var rejectedBridge = _bridge; + var rejectedTransport = _transport; + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + + Selection = previous.Selection; + _transport = previous.Transport; + _bridge = previous.Bridge; + rendezvousEndpoint = previous.RendezvousEndpoint; + verseFilter = previous.VerseFilter; + providerFilter = previous.ProviderFilter; + surfaceFilter = previous.SurfaceFilter; + surfaceKind = previous.SurfaceKind; + _authorityTrust = previous.AuthorityTrust; + AttachPrepared(_transport, _bridge); + _previousProvider = null; + _navigationRouteCommitted = false; + _preparation = Task.CompletedTask; + SafeDispose(rejectedBridge); + SafeDispose(rejectedTransport); } public void Refresh() { - if (Bridge.IsConnected) - Bridge.Refresh(); + var activeBridge = _navigationRouteCommitted + ? Bridge + : _previousProvider?.Bridge ?? Bridge; + if (activeBridge.IsConnected) + activeBridge.Refresh(); else - Bridge.Connect(); + activeBridge.Connect(); } public void Submit(EveSurfaceCommandRequest request) { - Bridge.Submit(request); + // Navigation preparation is not a command-routing commit. The mounted + // presentation continues to target its old provider until the visual + // candidate has been lowered and accepted by the bootstrap. + (_navigationRouteCommitted ? Bridge : _previousProvider?.Bridge ?? Bridge).Submit(request); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) @@ -155,6 +282,12 @@ namespace GameCult.Eve.UnityScene private void OnDestroy() { + ReleaseTransport(); + } + + private void ReleaseTransport() + { + _preparationLifetime?.Cancel(); if (_bridge != null) { _bridge.DocumentAvailable -= ForwardDocument; @@ -162,16 +295,24 @@ namespace GameCult.Eve.UnityScene } _bridge?.Dispose(); _transport?.Dispose(); + _previousProvider?.Bridge?.Dispose(); + _previousProvider?.Transport?.Dispose(); + _previousProvider = null; + _navigationRouteCommitted = false; + _connectionLeaseCount = 0; while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); while (_pendingFields.TryDequeue(out _)) { } _bridge = null; _transport = null; _preparation = null; + _preparationLifetime?.Dispose(); + _preparationLifetime = null; Selection = null; } private void Update() { + _previousProvider?.Transport?.PumpLiveEvents(); _transport?.PumpLiveEvents(); EveFieldsSplatsDocument? latestFields = null; while (_pendingFields.TryDequeue(out var fields)) @@ -199,7 +340,7 @@ namespace GameCult.Eve.UnityScene } } - private async Task PrepareTransportAsync() + private async Task PrepareTransportAsync(CancellationToken cancellationToken) { var elapsed = Stopwatch.StartNew(); if (_transport != null) @@ -207,37 +348,183 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync( - rendezvousEndpoint, + var prepared = await PrepareCandidateAsync( + new[] { rendezvousEndpoint }, providerFilter, surfaceFilter, surfaceKind, - verseFilter); - TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); - elapsed.Restart(); - var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath) - ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc") - : replicaPath; - _transport = new EveUnityCultMeshLiveProviderTransport( - resolvedReplicaPath, - Selection.Endpoint, - Selection.ProviderId, - Selection.SurfaceId, - runtimeId); - _transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); - _transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields); - _bridge = new EveUnitySceneLiveProviderBridge(_transport); - _bridge.DocumentAvailable += ForwardDocument; - _bridge.ReceiptAvailable += ForwardReceipt; - TraceStartup($"transport-construction {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); + verseFilter, + "", + _authorityTrust, + _navigationAuthorityTrust, + cancellationToken).ConfigureAwait(false); + TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); + AdoptPrepared(prepared); } + private async Task PrepareCandidateAsync( + IReadOnlyList endpoints, + string requiredProviderId, + string requiredSurfaceId, + string requiredSurfaceKind, + string requiredVerseId, + string requiredAuthorityRuntimeId, + CultMeshAuthorityTrustPolicy trust, + CultMeshAuthorityTrustPolicy crossTargetTrust, + CancellationToken cancellationToken) + { + var failures = new List(); + foreach (var endpoint in endpoints) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var selection = await new EveUnityCultMeshProviderDiscovery(trust).DiscoverAsync( + endpoint, + requiredProviderId, + requiredSurfaceId, + requiredSurfaceKind, + requiredVerseId, + cancellationToken, + requiredAuthorityRuntimeId).ConfigureAwait(false); + var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) + ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") + : cacheDirectory; + var transport = new EveUnityCultMeshLiveProviderTransport( + resolvedCachePath, + selection.RendezvousEndpoint, + selection.VerseId, + selection.AuthorityRuntimeId, + selection.ProviderId, + selection.SurfaceId, + runtimeId, + authorityTrust: trust, + crossTargetAuthorityTrust: crossTargetTrust); + try + { + await transport.PrepareAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new PreparedProvider(selection, transport, new EveUnitySceneLiveProviderBridge(transport)); + } + catch + { + transport.Dispose(); + throw; + } + } + catch (Exception error) when (error is not OperationCanceledException) + { + failures.Add($"{endpoint}: {error.Message}"); + } + } + + throw new InvalidOperationException( + $"No configured Odin endpoint prepared Verse '{requiredVerseId}' surface '{requiredSurfaceId}'. " + + string.Join(" | ", failures)); + } + + private void AdoptPrepared(PreparedProvider prepared) + { + if (_previousProvider != null) + throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first."); + var oldBridge = _bridge; + var oldTransport = _transport; + DetachPrepared(oldTransport, oldBridge); + + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; + AttachPrepared(_transport, _bridge); + + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + oldBridge?.Dispose(); + oldTransport?.Dispose(); + } + + private void StagePrepared(PreparedProvider prepared) + { + if (_previousProvider != null) + { + prepared.Bridge.Dispose(); + prepared.Transport.Dispose(); + throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first."); + } + + _previousProvider = new PreviousProvider( + Selection, + _transport, + _bridge, + rendezvousEndpoint, + verseFilter, + providerFilter, + surfaceFilter, + surfaceKind, + _authorityTrust); + _navigationRouteCommitted = false; + DetachPrepared(_transport, _bridge); + if (_previousProvider.Bridge != null) + _previousProvider.Bridge.ReceiptAvailable += ForwardReceipt; + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + } + + private void AttachPrepared( + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge) + { + if (transport != null) + { + transport.EntityViewAvailable += QueueEntityView; + transport.FieldsSplatsAvailable += QueueFields; + } + if (bridge != null) + { + bridge.DocumentAvailable += ForwardDocument; + bridge.ReceiptAvailable += ForwardReceipt; + } + } + + private void DetachPrepared( + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge) + { + if (transport != null) + { + transport.EntityViewAvailable -= QueueEntityView; + transport.FieldsSplatsAvailable -= QueueFields; + } + if (bridge == null) return; + bridge.DocumentAvailable -= ForwardDocument; + bridge.ReceiptAvailable -= ForwardReceipt; + } + + private void QueueEntityView(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease) => + _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); + + private void QueueFields(EveFieldsSplatsDocument fields) => _pendingFields.Enqueue(fields); + private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) => DocumentAvailable?.Invoke(document); private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) => ReceiptAvailable?.Invoke(receipt); + private static void SafeDispose(IDisposable? disposable) + { + try + { + disposable?.Dispose(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve provider cleanup failed after navigation ownership changed: {error}"); + } + } + private static void TraceStartup(string message) { if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal)) @@ -262,5 +549,57 @@ namespace GameCult.Eve.UnityScene public EveEntitySoaViewDocument Document { get; } public ICultMeshBodyReadLease Lease { get; } } + + private sealed class PreparedProvider + { + public PreparedProvider( + EveUnityCultMeshProviderSelection selection, + EveUnityCultMeshLiveProviderTransport transport, + EveUnitySceneLiveProviderBridge bridge) + { + Selection = selection; + Transport = transport; + Bridge = bridge; + } + + public EveUnityCultMeshProviderSelection Selection { get; } + public EveUnityCultMeshLiveProviderTransport Transport { get; } + public EveUnitySceneLiveProviderBridge Bridge { get; } + } + + private sealed class PreviousProvider + { + public PreviousProvider( + EveUnityCultMeshProviderSelection? selection, + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge, + string rendezvousEndpoint, + string verseFilter, + string providerFilter, + string surfaceFilter, + string surfaceKind, + CultMeshAuthorityTrustPolicy authorityTrust) + { + Selection = selection; + Transport = transport; + Bridge = bridge; + RendezvousEndpoint = rendezvousEndpoint; + VerseFilter = verseFilter; + ProviderFilter = providerFilter; + SurfaceFilter = surfaceFilter; + SurfaceKind = surfaceKind; + AuthorityTrust = authorityTrust; + } + + public EveUnityCultMeshProviderSelection? Selection { get; } + public EveUnityCultMeshLiveProviderTransport? Transport { get; } + public EveUnitySceneLiveProviderBridge? Bridge { get; } + public string RendezvousEndpoint { get; } + public string VerseFilter { get; } + public string ProviderFilter { get; } + public string SurfaceFilter { get; } + public string SurfaceKind { get; } + public CultMeshAuthorityTrustPolicy AuthorityTrust { get; } + } } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index 11438de..7772dbc 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Mesh; @@ -13,21 +14,24 @@ namespace GameCult.Eve.UnityScene public sealed class EveUnityCultMeshProviderSelection { public EveUnityCultMeshProviderSelection( - string endpoint, + string rendezvousEndpoint, string verseId, + string authorityRuntimeId, string providerId, string surfaceId, string surfaceKind) { - Endpoint = endpoint ?? ""; + RendezvousEndpoint = rendezvousEndpoint ?? ""; VerseId = verseId ?? ""; + AuthorityRuntimeId = authorityRuntimeId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; } - public string Endpoint { get; } + public string RendezvousEndpoint { get; } public string VerseId { get; } + public string AuthorityRuntimeId { get; } public string ProviderId { get; } public string SurfaceId { get; } public string SurfaceKind { get; } @@ -35,26 +39,22 @@ namespace GameCult.Eve.UnityScene public sealed class EveUnityCultMeshProviderDiscovery { - private static readonly Type[] AdvertisementDocumentTypes = - { - typeof(EveProviderAdvertisementDocument) - }; + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; - public EveUnityCultMeshProviderSelection Discover( - string rendezvousEndpoint, - string providerId = "", - string surfaceId = "", - string surfaceKind = "interactive-world", - string verseId = "") => - DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId) - .GetAwaiter().GetResult(); + public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null) + { + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + } public async Task DiscoverAsync( string rendezvousEndpoint, string providerId = "", string surfaceId = "", string surfaceKind = "interactive-world", - string verseId = "") + string verseId = "", + CancellationToken cancellationToken = default, + string requiredAuthorityRuntimeId = "") { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); @@ -62,13 +62,14 @@ namespace GameCult.Eve.UnityScene CultMeshVerseCatalogResponseMessage response; try { - response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync( + response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync( rendezvousEndpoint, new CultMeshVerseCatalogRequestMessage { VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId }, TransportVersion = "cultmesh.v0" }); + cancellationToken.ThrowIfCancellationRequested(); } catch (Exception error) { @@ -80,79 +81,87 @@ namespace GameCult.Eve.UnityScene var candidates = response.Verses .Where(verse => string.IsNullOrWhiteSpace(verseId) || string.Equals(verse.VerseId, verseId, StringComparison.Ordinal)) - .SelectMany(verse => (verse.DiscoveryEndpoints ?? Array.Empty()) - .Select(endpoint => new { Verse = verse, Endpoint = endpoint })) - .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Endpoint)) + .Where(verse => !string.IsNullOrWhiteSpace(verse.VerseId) && + (verse.DiscoveryEndpoints ?? Array.Empty()).Any(endpoint => + !string.IsNullOrWhiteSpace(endpoint))) + .GroupBy(verse => verse.VerseId, StringComparer.Ordinal) + .Select(group => group.First()) .ToArray(); if (candidates.Length == 0) throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints."); var failures = new List(); var observed = new List(); + using var mesh = new CultMeshClient(new CultMeshClientOptions + { + RendezvousEndpoints = new[] { rendezvousEndpoint }, + Discovery = EveUnityCultMeshConnectivity.Discovery(), + Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors() + }); foreach (var candidate in candidates) { - try + foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds( + candidate.AuthorityRuntimeIds, + requiredAuthorityRuntimeId)) { - var advertisements = await FetchAdvertisementsAsync(candidate.Endpoint); - observed.AddRange(advertisements.Select(document => - $"{candidate.Endpoint}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); - var advertisement = advertisements - .Where(document => string.IsNullOrWhiteSpace(providerId) || - string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) - .Select(document => new - { - Document = document, - Surface = document.Surfaces.FirstOrDefault(surface => - (string.IsNullOrWhiteSpace(surfaceId) || - string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) && - (string.IsNullOrWhiteSpace(surfaceKind) || - string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal))) - }) - .FirstOrDefault(match => match.Surface != null); - if (advertisement?.Surface == null) - continue; + try + { + var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId); + using var advertisementsLease = await mesh + .LeaseCollectionAsync(target, cancellationToken) + .ConfigureAwait(false); + var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false); + observed.AddRange(advertisements.Select(document => + $"{target}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); + var advertisement = advertisements + .Where(document => string.IsNullOrWhiteSpace(providerId) || + string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) + .Select(document => new + { + Document = document, + Surface = document.Surfaces.FirstOrDefault(surface => + (string.IsNullOrWhiteSpace(surfaceId) || + string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) && + (string.IsNullOrWhiteSpace(surfaceKind) || + string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal))) + }) + .FirstOrDefault(match => match.Surface != null); + if (advertisement?.Surface == null) + continue; - return new EveUnityCultMeshProviderSelection( - candidate.Endpoint, - candidate.Verse.VerseId, - advertisement.Document.ProviderId, - advertisement.Surface.SurfaceId, - advertisement.Surface.SurfaceKind); - } - catch (Exception error) - { - failures.Add($"{candidate.Endpoint}: {error.Message}"); + return new EveUnityCultMeshProviderSelection( + rendezvousEndpoint, + candidate.VerseId, + authorityRuntimeId, + advertisement.Document.ProviderId, + advertisement.Surface.SurfaceId, + advertisement.Surface.SurfaceKind); + } + catch (Exception error) + { + failures.Add($"{candidate.VerseId}/{authorityRuntimeId}: {error.Message}"); + } } } - var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'"; + var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'"; var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}"; var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}"; throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}"); } - private static async Task FetchAdvertisementsAsync(string endpoint) - { - var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes); - var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry); - var snapshot = CultMesh.SnapshotEndpoint( - endpoint, - new CultMeshSnapshotEndpointOptions - { - Context = CultMesh.Verse("eve.discovery", "eve-unity").Context, - DocumentRegistry = networkRegistry, - Request = new CultMeshSnapshotRequestOptions - { - ShardId = "provider", - ShardEpoch = 1, - ConnectTimeout = TimeSpan.FromSeconds(5), - ResponseTimeout = TimeSpan.FromSeconds(10), - MessageIdPrefix = "eve-unity-discovery", - RudpRuntimeId = "eve-unity.discovery", - RudpMaxFragmentBytes = 1024 - } - }); - return (await snapshot.FetchDocumentsAsync()).ToArray(); - } + internal static IReadOnlyList EligibleAuthorityRuntimeIds( + IEnumerable? authorityRuntimeIds, + string requiredAuthorityRuntimeId) => + (authorityRuntimeIds ?? Array.Empty()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Where(id => string.IsNullOrWhiteSpace(requiredAuthorityRuntimeId) || + string.Equals(id, requiredAuthorityRuntimeId, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef index ba5091b..671a17f 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef @@ -16,7 +16,8 @@ "GameCult.Caching.MessagePack.dll", "GameCult.Mesh.dll", "GameCult.Mesh.Quic.Native.dll", - "GameCult.Networking.dll" + "GameCult.Networking.dll", + "GameCult.Networking.WebSockets.dll" ], "autoReferenced": true, "defineConstraints": [], diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index fcd0924..ea39b34 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using UnityEngine; #nullable enable @@ -29,13 +30,378 @@ 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) { + UnbindNavigation(); provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent)); providerSurfaceDocuments = provider; commandSink = provider; assetManifestDocuments = provider; receiptSource = provider; + BindNavigation(); + } + + private void OnDestroy() + { + UnbindNavigation(); + _ownedPresentation?.Dispose(); + _ownedPresentation = null; + } + + private void BindNavigation() + { + if (provider is not IEveUnityNavigableProvider || provider is not IEveUnitySceneCommandReceiptSource source) + return; + _navigationReceiptSource = source; + source.ReceiptAvailable += OnReceiptAvailable; + } + + private void UnbindNavigation() + { + if (_navigationReceiptSource != null) + _navigationReceiptSource.ReceiptAvailable -= OnReceiptAvailable; + _navigationReceiptSource = null; + } + + private void OnReceiptAvailable(EveUnitySceneCommandReceipt receipt) + { + if (_navigationInProgress || receipt.Navigation == null) + return; + if (!string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) && + !string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) + return; + StartCoroutine(NavigateAsync(receipt.Navigation)); + } + + private IEnumerator NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (provider is not IEveUnityNavigableProvider navigable) + yield break; + _navigationInProgress = true; + var navigation = navigable.NavigateAsync(target); + 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}"); + } + finally + { + _navigationInProgress = false; + } + } + + 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() @@ -191,4 +557,21 @@ namespace GameCult.Eve.UnityScene return null; } } + + public sealed class EveUnitySceneNavigationFailure + { + public EveUnitySceneNavigationFailure( + EveUnitySceneNavigationTarget target, + string diagnostic, + DateTimeOffset failedAtUtc) + { + Target = target; + Diagnostic = diagnostic; + FailedAtUtc = failedAtUtc; + } + + public EveUnitySceneNavigationTarget Target { get; } + public string Diagnostic { get; } + public DateTimeOffset FailedAtUtc { get; } + } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index 41ca20f..f8d7182 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using GameCult.Eve.Surface; #nullable enable @@ -234,6 +235,43 @@ namespace GameCult.Eve.UnityScene event Action ReceiptAvailable; } + public interface IEveUnityNavigableProvider + { + Task NavigateAsync(EveUnitySceneNavigationTarget target); + + void CommitNavigation(); + + void FinalizeNavigation(); + + void RollbackNavigation(); + } + + public sealed class EveUnitySceneNavigationTarget + { + public EveUnitySceneNavigationTarget( + string verseId, + string providerId, + string surfaceId, + string surfaceKind, + IReadOnlyList? rendezvousEndpoints = null, + string authorityRuntimeId = "") + { + VerseId = verseId ?? ""; + AuthorityRuntimeId = authorityRuntimeId ?? ""; + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + SurfaceKind = surfaceKind ?? ""; + RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty(); + } + + public string VerseId { get; } + public string AuthorityRuntimeId { get; } + public string ProviderId { get; } + public string SurfaceId { get; } + public string SurfaceKind { get; } + public IReadOnlyList RendezvousEndpoints { get; } + } + public sealed class EveUnitySceneCommandReceipt { public EveUnitySceneCommandReceipt( @@ -248,7 +286,8 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string message = "", DateTimeOffset? issuedAtUtc = null, - long sourceVersion = 0) + long sourceVersion = 0, + EveUnitySceneNavigationTarget? navigation = null) { ReceiptId = receiptId ?? ""; Command = command ?? ""; @@ -262,6 +301,7 @@ namespace GameCult.Eve.UnityScene Message = message ?? ""; IssuedAtUtc = issuedAtUtc; SourceVersion = sourceVersion; + Navigation = navigation; } public string Schema { get; } @@ -288,6 +328,8 @@ namespace GameCult.Eve.UnityScene public long SourceVersion { get; } + public EveUnitySceneNavigationTarget? Navigation { get; } + public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal); public bool ShouldRefreshProviderSurface => diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index 77b9d1f..b14f549 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -172,7 +172,11 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("exposureAdaptation", "progressive"), ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f), ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f), - worldRoot.GetProp("colorGradingSpace")); + worldRoot.GetProp("colorGradingSpace"), + worldRoot.GetProp("assetProviderId"), + worldRoot.GetProp("assetVerseId"), + worldRoot.GetProp("assetAuthorityRuntimeId"), + ParseStringList(worldRoot.GetProp("assetRendezvousEndpoints"))); } private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) => @@ -270,7 +274,7 @@ namespace GameCult.Eve.UnityScene private static IReadOnlyList ParseStringList(string value) { return (value ?? "") - .Split(',') + .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Trim()) .Where(part => !string.IsNullOrWhiteSpace(part)) .Distinct(StringComparer.Ordinal) @@ -443,7 +447,11 @@ namespace GameCult.Eve.UnityScene string exposureAdaptation = "progressive", float exposureSpeedUp = 2f, float exposureSpeedDown = 1f, - string colorGradingSpace = "") + string colorGradingSpace = "", + string assetProviderId = "", + string assetVerseId = "", + string assetAuthorityRuntimeId = "", + IReadOnlyList? assetRendezvousEndpoints = null) { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -507,6 +515,10 @@ namespace GameCult.Eve.UnityScene ExposureSpeedUp = exposureSpeedUp; ExposureSpeedDown = exposureSpeedDown; ColorGradingSpace = colorGradingSpace ?? ""; + AssetProviderId = assetProviderId ?? ""; + AssetVerseId = assetVerseId ?? ""; + AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? ""; + AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty(); } public string WorldRootId { get; } @@ -525,6 +537,14 @@ namespace GameCult.Eve.UnityScene public string AssetManifest { get; } + public string AssetProviderId { get; } + + public string AssetVerseId { get; } + + public string AssetAuthorityRuntimeId { get; } + + public IReadOnlyList AssetRendezvousEndpoints { get; } + public string InputProfile { get; } public string CameraRig { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs index 34fa300..fb4481b 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs @@ -15,16 +15,20 @@ namespace GameCult.Eve.UnityScene var document = host.GetComponent(); if (document != null) return document; - var theme = Resources.Load(ThemeResource); - if (theme == null) - throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing."); + var panel = host.transform.parent?.GetComponentInParent()?.panelSettings; + if (panel == null) + { + var theme = Resources.Load(ThemeResource); + if (theme == null) + throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing."); - var panel = ScriptableObject.CreateInstance(); - panel.name = "Eve playable-world HUD panel"; - panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; - panel.referenceResolution = new Vector2Int(1920, 1080); - panel.match = 0.5f; - panel.themeStyleSheet = theme; + panel = ScriptableObject.CreateInstance(); + panel.name = "Eve playable-world HUD panel"; + panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; + panel.referenceResolution = new Vector2Int(1920, 1080); + panel.match = 0.5f; + panel.themeStyleSheet = theme; + } document = host.AddComponent(); document.panelSettings = panel; diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index ddc3e21..376bc8a 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -2,11 +2,15 @@ using System; using System.Reflection; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Sockets; using System.IO; +using System.Threading; +using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Eve.UnityScene; +using GameCult.Mesh; using NUnit.Framework; using UnityEngine; @@ -14,6 +18,523 @@ 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() + { + var selection = new EveUnityCultMeshProviderSelection( + "cultnet+tcp://odin.example:3075", + "aetheria.public", + "aetheria.public", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world"); + + Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075")); + 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() + { + var fields = typeof(EveUnityCultMeshLiveProviderTransport) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic); + var names = Array.ConvertAll(fields, field => field.Name); + + CollectionAssert.Contains(names, "_meshClient"); + CollectionAssert.DoesNotContain(names, "_node"); + CollectionAssert.DoesNotContain(names, "_snapshot"); + CollectionAssert.DoesNotContain(names, "_networkRegistry"); + CollectionAssert.DoesNotContain(names, "_replicaShard"); + CollectionAssert.DoesNotContain(names, "_subscriptions"); + CollectionAssert.DoesNotContain(names, "_entitySubscriptions"); + } + [Test] public void UnavailableTcpRendezvousIsReportedAsRetryablePreparationFailure() { @@ -125,28 +646,16 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void PublishingCatalogRegistersBundlesWithoutMaterializingThem() + public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem() { var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( "CurrentBundlePlatform", BindingFlags.Static | BindingFlags.NonPublic); - var publishMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( - "PublishAssetCatalog", - BindingFlags.Instance | BindingFlags.NonPublic); - var bundlesField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( - "_assetBundles", - BindingFlags.Instance | BindingFlags.NonPublic); - var selectionsField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( - "_assetSelections", - BindingFlags.Instance | BindingFlags.NonPublic); + var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "ConfigureAssetCatalog", + BindingFlags.Static | BindingFlags.NonPublic); Assert.That(platformMethod, Is.Not.Null); - Assert.That(publishMethod, Is.Not.Null); - - using var transport = new EveUnityCultMeshLiveProviderTransport( - Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"), - "cultnet+tcp://127.0.0.1:1", - "provider", - "surface"); + Assert.That(configureMethod, Is.Not.Null); var variant = new EveAssetVariant( "unity-scene", (string)platformMethod!.Invoke(null, Array.Empty())!, @@ -161,11 +670,229 @@ namespace GameCult.Eve.UnityScene.Tests 1, DateTimeOffset.UtcNow.ToString("O"), new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) }); + var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1); - publishMethod!.Invoke(transport, new object[] { catalog }); + configureMethod!.Invoke(null, new[] { candidate, catalog }); - Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero); - Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).Count, Is.EqualTo(1)); + Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero); + Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1)); + ((IDisposable)candidate).Dispose(); } + + [Test] + public void FailedAssetCandidatePreservesCommittedGeneration() + { + var transportType = typeof(EveUnityCultMeshLiveProviderTransport); + var generationField = transportType.GetField( + "_assetGeneration", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var configureMethod = transportType.GetMethod( + "ConfigureAssetCatalog", + BindingFlags.Static | BindingFlags.NonPublic)!; + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a"); + var initial = (IDisposable)generationField.GetValue(transport)!; + var committed = CreateAssetGeneration( + "verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 1); + var prefab = new GameObject("committed-a"); + GenerationDictionary(committed, "Prefabs")["prefab.a"] = prefab; + initial.Dispose(); + generationField.SetValue(transport, committed); + + var invalidVariant = new EveAssetVariant( + "browser", + "web", + "url", + "https://example.invalid/a", + "sha256:00", + 1, + "prefab.a"); + var invalidCatalog = new EveAssetCatalogDocument( + "provider-b", + "catalog-b", + 2, + DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) }); + var rejected = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1); + var error = Assert.Throws(() => + configureMethod.Invoke(null, new[] { rejected, invalidCatalog })); + Assert.That(error!.InnerException, Is.TypeOf()); + ((IDisposable)rejected).Dispose(); + Assert.That(generationField.GetValue(transport), Is.SameAs(committed)); + Assert.That(GenerationProperty(committed, "SourceIdentity"), Is.EqualTo("source-a")); + Assert.That(GenerationDictionary(committed, "Prefabs")["prefab.a"], Is.SameAs(prefab)); + UnityEngine.Object.DestroyImmediate(prefab); + } + + [Test] + public void CandidateCatalogObservationSurvivesPrecommitWindow() + { + var candidate = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", 1); + var candidateType = candidate.GetType(); + var observe = candidateType.GetMethod("ObserveCatalog")!; + var activate = candidateType.GetMethod("ActivateCatalogObservation")!; + var variant = new EveAssetVariant( + "unity-scene", "StandaloneWindows64", "url", "https://example.invalid/b", "sha256:01", 1, "prefab.b"); + var catalogB = new EveAssetCatalogDocument( + "provider-b", "catalog-b", 2, DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { variant }) }); + + Assert.That(observe.Invoke(candidate, new object[] { catalogB }), Is.Null); + Assert.That(activate.Invoke(candidate, Array.Empty()), Is.SameAs(catalogB)); + ((IDisposable)candidate).Dispose(); + } + + [Test] + public void PinnedSurfaceCatalogDoesNotAutoAdvanceWithoutANewerBaseSurface() + { + var transportType = typeof(EveUnityCultMeshLiveProviderTransport); + var generationField = transportType.GetField( + "_assetGeneration", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var pendingField = transportType.GetField( + "_pendingAssetCatalog", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var queue = transportType.GetMethod( + "QueueAssetCatalogUpdate", + BindingFlags.Instance | BindingFlags.NonPublic)!; + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-pinned-catalog-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a"); + var initial = (IDisposable)generationField.GetValue(transport)!; + var committed = CreateAssetGeneration( + "verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 41, 41); + initial.Dispose(); + generationField.SetValue(transport, committed); + var catalog42 = new EveAssetCatalogDocument( + "provider-a", + "catalog-a", + 42, + DateTimeOffset.UtcNow.ToString("O"), + Array.Empty()); + + queue.Invoke(transport, new object[] { "source-a", catalog42 }); + + Assert.That(GenerationProperty(generationField.GetValue(transport)!, "CatalogVersion"), Is.EqualTo(41)); + var committedSource = GenerationProperty(generationField.GetValue(transport)!, "Source"); + Assert.That(GenerationProperty(committedSource, "RequiredCatalogVersion"), Is.EqualTo(41)); + Assert.That(pendingField.GetValue(transport), Is.Null); + } + + [Test] + public void PinnedSurfaceRejectsWrongImmutableCatalogRecordImmediately() + { + var require = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "RequireCatalogVersion", + BindingFlags.Static | BindingFlags.NonPublic)!; + var catalog42 = new EveAssetCatalogDocument( + "provider-a", + "eve:assets:aetheria.daemon:version:42", + 42, + DateTimeOffset.UtcNow.ToString("O"), + Array.Empty()); + + var error = Assert.Throws(() => require.Invoke( + null, + new object[] { catalog42, 41L, "eve:assets:aetheria.daemon:version:41" })); + + Assert.That(error!.InnerException, Is.TypeOf()); + Assert.That(error.InnerException!.Message, Does.Contain("requires immutable version 41")); + } + + [Test] + public void CrossTargetAssetsUseConfiguredRemoteTrust() + { + var localTrust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment); + var remoteTrust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.AuthenticatedRemote); + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-asset-trust-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a", + authorityTrust: localTrust, + crossTargetAuthorityTrust: remoteTrust); + var trustMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "AssetAuthorityTrust", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + var localSource = CreateAssetSource("verse-a", "authority-a", "provider-a", "catalog-a"); + var remoteSource = CreateAssetSource("verse-b", "authority-b", "provider-b", "catalog-b"); + + Assert.That(trustMethod.Invoke(transport, new[] { localSource }), Is.SameAs(localTrust)); + Assert.That(trustMethod.Invoke(transport, new[] { remoteSource }), Is.SameAs(remoteTrust)); + } + + private static object CreateAssetSource( + string verseId, + string authorityRuntimeId, + string providerId, + string manifestRecordRef, + long requiredCatalogVersion = 0) + { + var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType( + "AssetSource", + BindingFlags.NonPublic)!; + return Activator.CreateInstance( + type, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new object[] + { + new CultMeshSessionTarget(verseId, authorityRuntimeId), + providerId, + manifestRecordRef, + Array.Empty(), + requiredCatalogVersion + }, + null)!; + } + + private static object CreateAssetGeneration( + string verseId, + string authorityRuntimeId, + string providerId, + string manifestRecordRef, + string sourceIdentity, + long catalogVersion, + long requiredCatalogVersion = 0) + { + var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType( + "AssetGeneration", + BindingFlags.NonPublic)!; + return Activator.CreateInstance( + type, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] + { + CreateAssetSource(verseId, authorityRuntimeId, providerId, manifestRecordRef, requiredCatalogVersion), + sourceIdentity, + catalogVersion, + null + }, + null)!; + } + + private static System.Collections.IList GenerationCollection(object generation, string property) => + (System.Collections.IList)generation.GetType().GetProperty(property)!.GetValue(generation)!; + + private static System.Collections.IDictionary GenerationDictionary(object generation, string property) => + (System.Collections.IDictionary)generation.GetType().GetProperty(property)!.GetValue(generation)!; + + private static T GenerationProperty(object generation, string property) => + (T)generation.GetType().GetProperty(property)!.GetValue(generation)!; } } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index a65771c..7f83143 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -3,6 +3,9 @@ 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; using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; @@ -12,6 +15,7 @@ using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.UIElements; +using UnityEngine.TestTools; #nullable enable @@ -25,9 +29,11 @@ namespace GameCult.Eve.UnityScene.Tests Assert.DoesNotThrow(() => { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "aetheria", + "aetheria", + "aetheria", "aetheria.pilot"); }); } @@ -59,9 +65,11 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportReusesStableLayoutForNewBodyPublications() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "aetheria", + "aetheria", + "aetheria", "aetheria.pilot"); var view = EntityLeaseDocument(); var publication = BodyPublication(view, view.Sequence + 1); @@ -93,9 +101,11 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportPairsBodyPublicationThatArrivesBeforeItsLayout() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "aetheria", + "aetheria", + "aetheria", "aetheria.pilot"); var view = EntityLeaseDocument(); var publication = BodyPublication(view, view.Sequence); @@ -121,9 +131,11 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportKeepsOnlyLatestPendingRealtimeGenerationPerBody() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "provider", + "provider", + "provider", "provider.pilot"); var enqueue = typeof(EveUnityCultMeshLiveProviderTransport) .GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -150,9 +162,11 @@ namespace GameCult.Eve.UnityScene.Tests public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments() { using var transport = new EveUnityCultMeshLiveProviderTransport( - "test-replica.cc", + "test-cache", "cultnet://127.0.0.1:3075", "provider", + "provider", + "provider", "provider.pilot"); var view = EntityLeaseDocument(); view.Sequence = 0; @@ -231,14 +245,20 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void LiveTransportDoesNotRegisterBodyBytesAsSnapshotDocuments() + public void LiveTransportHasNoSnapshotWireRegistryForBodyBytes() { - var wireTypes = (Type[])typeof(EveUnityCultMeshLiveProviderTransport) - .GetField("WireDocumentTypes", BindingFlags.NonPublic | BindingFlags.Static)! - .GetValue(null)!; + var transport = typeof(EveUnityCultMeshLiveProviderTransport); + var wireRegistry = transport.GetField( + "WireDocumentTypes", + BindingFlags.NonPublic | BindingFlags.Static); + var fieldTypes = transport + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) + .Select(field => field.FieldType) + .ToArray(); - Assert.That(wireTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False); - Assert.That(wireTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False); + Assert.That(wireRegistry, Is.Null); + Assert.That(fieldTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False); + Assert.That(fieldTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False); } [Test] @@ -562,6 +582,10 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(projection.PlayableWorld.EntityBodyId, Is.EqualTo("eve:entity-soa:aetheria.daemon:pilot")); Assert.That(projection.PlayableWorld.ZoneRenderPointerId, Is.EqualTo("cultmesh://aetheria/world/zone-render")); Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(projection.PlayableWorld.AssetProviderId, Is.EqualTo("aetheria.assets")); + Assert.That(projection.PlayableWorld.AssetVerseId, Is.EqualTo("aetheria.mods")); + Assert.That(projection.PlayableWorld.AssetAuthorityRuntimeId, Is.EqualTo("mod-authority")); + Assert.That(projection.PlayableWorld.AssetRendezvousEndpoints, Is.EqualTo(new[] { "wss://odin-a/cultmesh", "wss://odin-b/cultmesh" })); Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person")); Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1")); Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty); @@ -901,11 +925,19 @@ namespace GameCult.Eve.UnityScene.Tests "aetheria.daemon.move_intent", "accepted", "Aetheria", - "provider-owned-daemon")); + "provider-owned-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria", + "aetheria.pilot", + "interactive-world"))); Assert.That(runtime.LastReceipt, Is.Not.Null); Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True); Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True); + Assert.That(runtime.LastReceipt.Navigation, Is.Not.Null); + Assert.That(runtime.LastReceipt.Navigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(runtime.LastReceipt.Navigation.SurfaceId, Is.EqualTo("aetheria.pilot")); } [Test] @@ -1123,6 +1155,204 @@ namespace GameCult.Eve.UnityScene.Tests } } + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapFollowsProviderOwnedReceiptNavigation() + { + var hostObject = new GameObject("generic-eve-navigation-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalHost = bootstrap.Host; + var originalRoot = bootstrap.SceneRoot!; + provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf; + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.LastNavigation, Is.Not.Null); + Assert.That(provider.LastNavigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(provider.LastNavigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.example:3076" })); + Assert.That(bootstrap.Host, Is.Not.SameAs(originalHost)); + Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1)); + Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(1)); + Assert.That(provider.RollbackNavigationCount, Is.Zero); + Assert.That(provider.PreviousPresentationActiveDuringCommit, Is.False); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-again", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.EqualTo(2)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(2)); + Assert.That(provider.ActiveConnectionLeases, Is.EqualTo(1), + "disposing superseded presentations must leave the committed Verse connected"); + Assert.That(hostObject.transform.Cast() + .Count(child => child.name == "Eve Unity Candidate Presentation"), Is.EqualTo(1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapKeepsMountedPresentationAfterNavigationMountFailure() + { + var hostObject = new GameObject("generic-eve-navigation-rollback-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalHost = bootstrap.Host; + var originalRuntime = originalHost!.Runtime; + var originalRoot = bootstrap.SceneRoot; + var originalPresentation = bootstrap.LastPresentation; + provider.FailNextNavigationMount = true; + LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted")); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-failure", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "broken-provider", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3999" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.Zero); + Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1)); + Assert.That(bootstrap.LastNavigationFailure, Is.Not.Null); + Assert.That(bootstrap.Host, Is.SameAs(originalHost)); + Assert.That(bootstrap.Host!.Runtime, Is.SameAs(originalRuntime)); + Assert.That(bootstrap.SceneRoot, Is.SameAs(originalRoot)); + Assert.That(bootstrap.LastPresentation, Is.SameAs(originalPresentation)); + Assert.That(bootstrap.Host!.Runtime, Is.Not.Null); + Assert.That(bootstrap.Host.ActiveWorld, Is.Not.Null); + Assert.That(bootstrap.Host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(bootstrap.Host.ConnectionEpoch, Is.EqualTo(1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapRestoresProviderRouteBeforeReactivatingPreviousView() + { + var hostObject = new GameObject("generic-eve-navigation-order-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalRoot = bootstrap.SceneRoot!; + provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf; + provider.FailNextNavigationFinalize = true; + LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted")); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-finalize-failure", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "candidate-provider", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3999" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); + Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1)); + Assert.That(provider.ActiveRoute, Is.EqualTo("original")); + Assert.That(provider.PreviousPresentationActiveDuringRollback, Is.False); + Assert.That(originalRoot.gameObject.activeSelf, Is.True); + Assert.That(bootstrap.Host!.Runtime, Is.Not.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + [Test] public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector() { @@ -2504,6 +2734,10 @@ namespace GameCult.Eve.UnityScene.Tests ["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render", ["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1", ["assetManifest"] = "cultmesh://aetheria/assets/manifest", + ["assetProviderId"] = "aetheria.assets", + ["assetVerseId"] = "aetheria.mods", + ["assetAuthorityRuntimeId"] = "mod-authority", + ["assetRendezvousEndpoints"] = "wss://odin-a/cultmesh;wss://odin-b/cultmesh", ["inputProfile"] = "arpg-third-person", ["cameraRig"] = cameraRig, ["cameraLookAt"] = cameraLookAt, @@ -3208,12 +3442,38 @@ namespace GameCult.Eve.UnityScene.Tests IEveUnitySceneCommandSink, IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, - IEveUnityInputCapabilitySource + IEveUnityInputCapabilitySource, + IEveUnitySceneProviderSurfaceDocumentConnection, + IEveUnityNavigableProvider { public List Submitted { get; } = new List(); public int RefreshCount { get; private set; } + 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; @@ -3235,7 +3495,9 @@ namespace GameCult.Eve.UnityScene.Tests new EveInputCapabilityDocument(); EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => - CurrentSurfaceDocument; + _failSurfaceRead + ? throw new InvalidOperationException("Deliberate candidate surface read failure.") + : CurrentSurfaceDocument; public event Action? DocumentAvailable; @@ -3275,6 +3537,51 @@ namespace GameCult.Eve.UnityScene.Tests { ReceiptAvailable?.Invoke(receipt); } + + 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/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json index 4e07fed..244999a 100644 --- a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -160,14 +160,14 @@ "requiredPackageDependencies": [ { "packageName": "org.gamecult.eve.surface", - "version": "0.2.4", + "version": "0.3.0", "packageManager": "upm", - "ownerRepo": "EveUnity", - "purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents." + "ownerRepo": "Eve", + "purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents." }, { "packageName": "org.gamecult.eve.unity-uitoolkit", - "version": "0.1.2", + "version": "0.1.3", "packageManager": "upm", "ownerRepo": "EveUnity", "purpose": "Generic UI Toolkit lowering for provider-owned Eve panes, metrics, progress, text, and controls." diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index c84f62f..f5dcbe0 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,14 +1,14 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.103", + "version": "0.3.134", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.43", + "org.gamecult.cultlib": "1.0.56", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.2.4", - "org.gamecult.eve.unity-uitoolkit": "0.1.2", + "org.gamecult.eve.surface": "0.3.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index 00809f2..f5397a2 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -17,6 +17,7 @@ namespace GameCult.Eve.UnityUIToolkit private Vector2 _inventoryDragStart; private bool _inventoryPointerMoved; private bool _suppressInventoryClick; + private readonly List _inventoryPreviewCells = new(); public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null) { @@ -45,10 +46,16 @@ 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; + // Select options are semantic children consumed by the control itself. + // They are not separately lowered visual elements. + if (NormalizeKind(component.Kind) == "control.select") + return element; + foreach (var child in component.Children) { var loweredChild = LowerComponent(child, document, commandSink); @@ -114,6 +121,8 @@ namespace GameCult.Eve.UnityUIToolkit return InventoryItem(component, document, commandSink); case EveInventoryInteraction.DragSessionKind: return InventoryDragSession(component); + case "control.select": + return Select(component, document, commandSink); case "partition": { var element = new VisualElement(); @@ -123,6 +132,8 @@ namespace GameCult.Eve.UnityUIToolkit element.style.flexWrap = Wrap.Wrap; return element; } + case "scroll": + return new ScrollView(ScrollViewMode.Vertical); case "pane": case "modal": case "card": @@ -266,6 +277,7 @@ namespace GameCult.Eve.UnityUIToolkit return; if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink)) _inventoryDragSource = null; + ClearInventoryPreview(); }); return grid; } @@ -284,6 +296,7 @@ namespace GameCult.Eve.UnityUIToolkit _inventoryDragSource = component; _inventoryDragStart = evt.position; _inventoryPointerMoved = false; + ClearInventoryPreview(); item.CapturePointer(evt.pointerId); evt.StopPropagation(); }); @@ -293,7 +306,15 @@ namespace GameCult.Eve.UnityUIToolkit return; var pointerPosition = new Vector2(evt.position.x, evt.position.y); if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f) + { _inventoryPointerMoved = true; + var picked = item.panel?.Pick(evt.position); + var targetElement = InventoryGridAncestor(picked); + if (targetElement?.userData is EveSurfaceComponent target) + ShowInventoryPreview(component, target, targetElement, evt.position); + else + ClearInventoryPreview(); + } }); item.RegisterCallback(evt => { @@ -308,6 +329,7 @@ namespace GameCult.Eve.UnityUIToolkit TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink); _inventoryDragSource = null; _suppressInventoryClick = true; + ClearInventoryPreview(); } evt.StopPropagation(); }); @@ -399,6 +421,50 @@ namespace GameCult.Eve.UnityUIToolkit return true; } + private void ShowInventoryPreview( + EveSurfaceComponent source, + EveSurfaceComponent target, + VisualElement targetElement, + Vector2 panelPosition) + { + ClearInventoryPreview(); + var local = targetElement.WorldToLocal(panelPosition); + var cellSize = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f)); + var gap = Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f)); + var pitch = cellSize + gap; + var x = Math.Max(0, (int)Math.Floor(local.x / Math.Max(1f, pitch))); + var y = Math.Max(0, (int)Math.Floor(local.y / Math.Max(1f, pitch))); + if (!EveInventoryInteraction.TryCreatePlacementPreview(source, target, x, y, out var preview) || + preview == null) + return; + + foreach (var cell in preview.Cells) + { + var element = new VisualElement { pickingMode = PickingMode.Ignore }; + element.AddToClassList("eve-inventory-placement-preview"); + element.AddToClassList(preview.IsValid + ? "eve-inventory-placement-valid" + : "eve-inventory-placement-invalid"); + element.style.position = Position.Absolute; + element.style.left = cell.X * pitch; + element.style.top = cell.Y * pitch; + element.style.width = cellSize; + element.style.height = cellSize; + element.style.backgroundColor = preview.IsValid + ? new Color(0.2f, 0.9f, 0.65f, 0.34f) + : new Color(1f, 0.25f, 0.2f, 0.38f); + targetElement.Add(element); + _inventoryPreviewCells.Add(element); + } + } + + private void ClearInventoryPreview() + { + foreach (var cell in _inventoryPreviewCells) + cell.RemoveFromHierarchy(); + _inventoryPreviewCells.Clear(); + } + private static int ParseInt(string value, int fallback) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; @@ -448,10 +514,62 @@ namespace GameCult.Eve.UnityUIToolkit foreach (var template in document.Commands) { if (string.Equals(template.Command, command, StringComparison.Ordinal)) - return GameCult.Mesh.CultMesh.OperationInvocation(template.Operation); + return GameCult.Mesh.CultMesh.OperationInvocation( + template.Operation, + idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}"); } - return GameCult.Mesh.CultMesh.OperationInvocation(command); + return GameCult.Mesh.CultMesh.OperationInvocation( + command, + idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}"); + } + + private static VisualElement Select( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var options = component.Children + .Where(child => NormalizeKind(child.Kind) == "control.option") + .Select(child => new + { + Label = child.GetProp("label", child.GetProp("value")), + Value = child.GetProp("value") + }) + .Where(option => !string.IsNullOrWhiteSpace(option.Value)) + .ToArray(); + var duplicateLabels = options + .GroupBy(option => option.Label, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + var labels = options + .Select(option => duplicateLabels.Contains(option.Label) + ? $"{option.Label} [{option.Value}]" + : option.Label) + .ToList(); + var selectedValue = component.GetProp("value"); + var selectedIndex = Array.FindIndex(options, option => + string.Equals(option.Value, selectedValue, StringComparison.Ordinal)); + var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex)); + field.RegisterValueChangedCallback(change => + { + var index = field.index; + if (index < 0 || index >= options.Length) + return; + var option = options[index]; + var payload = new Dictionary(component.Props, StringComparer.Ordinal) + { + ["value"] = option.Value + }; + EmitCommand( + document, + component, + component.GetProp("command", component.GetProp("operationId")), + GameCult.Mesh.CultMesh.OperationPayload(payload), + commandSink); + }); + return field; } private static Label TitleLabel(string text) @@ -549,6 +667,12 @@ namespace GameCult.Eve.UnityUIToolkit element.style.alignItems = align; if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify)) element.style.justifyContent = justify; + if (TryGet(layout, "flexGrow", out var flexGrow)) + element.style.flexGrow = ParseFloat(flexGrow); + if (TryGet(layout, "flexShrink", out var flexShrink)) + element.style.flexShrink = ParseFloat(flexShrink); + if (TryGet(layout, "flexBasis", out var flexBasis)) + element.style.flexBasis = ParseLength(flexBasis); if (TryGet(layout, "width", out var width)) element.style.width = ParseLength(width); if (TryGet(layout, "minWidth", out var minWidth)) @@ -625,6 +749,14 @@ namespace GameCult.Eve.UnityUIToolkit return false; } + private static bool IsEnabled(EveSurfaceComponent component) + { + if (ParseBool(component.GetProp("disabled"))) + return false; + var enabled = component.GetProp("enabled"); + return string.IsNullOrWhiteSpace(enabled) || ParseBool(enabled); + } + private static StyleLength ParseLength(string value) { value = FirstToken(value); diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs index 38b2fcb..20b98bb 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using GameCult.Eve.Surface; using GameCult.Mesh; using NUnit.Framework; +using UnityEditor; using UnityEngine.UIElements; #nullable enable @@ -11,6 +13,108 @@ namespace GameCult.Eve.UnityUIToolkit.Tests { public sealed class EveUiToolkitSurfaceLowererTests { + [Test] + public void SelectLowersProviderOptionsAndEmitsSelectedTypedValue() + { + EveSurfaceCommandRequest? emitted = null; + var select = Component( + "verse", + "control.select", + new Dictionary + { + ["label"] = "VERSE", + ["value"] = "local", + ["command"] = "eve.client.verse.select" + }, + new[] + { + Component("local", "control.option", new Dictionary { ["label"] = "Local", ["value"] = "local" }), + Component("hosted", "control.option", new Dictionary { ["label"] = "Hosted", ["value"] = "hosted" }) + }); + var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request); + var field = root.Q(); + var window = EditorWindow.CreateInstance(); + + try + { + window.rootVisualElement.Add(root); + window.Show(); + Assert.That(field, Is.Not.Null); + Assert.That(field.choices, Is.EqualTo(new[] { "Local", "Hosted" })); + field.value = "Hosted"; + Assert.That(emitted, Is.Not.Null); + Assert.That(emitted!.Command, Is.EqualTo("eve.client.verse.select")); + Assert.That(emitted.PayloadFields["value"], Is.EqualTo("hosted")); + } + finally + { + window.Close(); + } + } + + [Test] + 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