Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5556c3194 | ||
|
|
8d0789ba0d | ||
|
|
c0670fd07c |
69 changed files with 3659 additions and 4199 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,6 +9,3 @@ TestProject/UserSettings/
|
||||||
obj/
|
obj/
|
||||||
*.csproj
|
*.csproj
|
||||||
*.sln
|
*.sln
|
||||||
packages/org.gamecult.eve.surface/bin~/
|
|
||||||
packages/org.gamecult.eve.surface/obj~/
|
|
||||||
packages/org.gamecult.eve.surface/Directory.Build.props.meta
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,7 @@ assembly is part of the client.
|
||||||
|
|
||||||
`EveUnityCultMeshPlayableWorldProvider` is the shipped composition root. Its
|
`EveUnityCultMeshPlayableWorldProvider` is the shipped composition root. Its
|
||||||
provider, surface, and Verse filters default to empty, so a client can select by
|
provider, surface, and Verse filters default to empty, so a client can select by
|
||||||
the generic `interactive-world` contract alone. After selection, Unity retains
|
the generic `interactive-world` contract alone.
|
||||||
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
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\run-package-tests.ps1
|
powershell -ExecutionPolicy Bypass -File .\scripts\run-package-tests.ps1
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
|
|
||||||
var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "";
|
var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "";
|
||||||
var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "";
|
var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "";
|
||||||
var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ??
|
var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ??
|
||||||
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}");
|
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc");
|
||||||
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ??
|
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ??
|
||||||
Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png");
|
Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png");
|
||||||
var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ??
|
var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ??
|
||||||
|
|
@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
provider = root.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
|
provider = root.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
|
||||||
provider.Configure(
|
provider.Configure(
|
||||||
rendezvousEndpoint,
|
rendezvousEndpoint,
|
||||||
cacheDirectory,
|
replicaPath,
|
||||||
providerId,
|
providerId,
|
||||||
surfaceId,
|
surfaceId,
|
||||||
requiredSurfaceKind: "interactive-world",
|
requiredSurfaceKind: "interactive-world",
|
||||||
|
|
@ -310,8 +310,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
|
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
|
||||||
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
|
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
|
||||||
rendezvousEndpoint,
|
rendezvousEndpoint,
|
||||||
provider.Selection.VerseId,
|
provider.Selection.VerseId);
|
||||||
provider.Selection.AuthorityRuntimeId);
|
|
||||||
var quicDeadline = Time.realtimeSinceStartup + 15f;
|
var quicDeadline = Time.realtimeSinceStartup + 15f;
|
||||||
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
|
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
|
||||||
yield return new WaitForSecondsRealtime(0.05f);
|
yield return new WaitForSecondsRealtime(0.05f);
|
||||||
|
|
@ -332,15 +331,19 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
provider,
|
provider,
|
||||||
provider);
|
provider);
|
||||||
host.Connect();
|
host.Connect();
|
||||||
var loweredSurface = new EveUiToolkitSurfaceLowerer().Lower(
|
var mountedOverlay = root.GetComponent<EveUnityUiToolkitOverlay>();
|
||||||
provider.CurrentDocument.SurfaceDocument,
|
Assert.That(mountedOverlay, Is.Not.Null);
|
||||||
provider.Submit);
|
var cockpit = mountedOverlay.Root.Q<VisualElement>("aetheria.daemon.game.cockpit");
|
||||||
var cockpit = loweredSurface.Q<VisualElement>("aetheria.daemon.game.cockpit");
|
|
||||||
Assert.That(cockpit, Is.Not.Null, "The provider did not publish a generic cockpit overlay.");
|
Assert.That(cockpit, Is.Not.Null, "The provider did not publish a generic cockpit overlay.");
|
||||||
cockpitProgressCount = cockpit.Query<ProgressBar>().ToList().Count;
|
cockpitProgressCount = cockpit.Query<ProgressBar>().ToList().Count;
|
||||||
Assert.That(cockpitProgressCount, Is.GreaterThanOrEqualTo(7));
|
Assert.That(cockpitProgressCount, Is.GreaterThanOrEqualTo(7));
|
||||||
Assert.That(loweredSurface.Q<VisualElement>("aetheria.daemon.game.frame").style.display.value,
|
Assert.That(mountedOverlay.Root.Q<VisualElement>("aetheria.daemon.game.frame").style.display.value,
|
||||||
Is.EqualTo(DisplayStyle.None), "Daemon diagnostics leaked into the pilot UI.");
|
Is.EqualTo(DisplayStyle.None), "Daemon diagnostics leaked into the mounted pilot UI.");
|
||||||
|
Assert.That(mountedOverlay.Root.Q<VisualElement>("aetheria.daemon.game.main_menu").style.display.value,
|
||||||
|
Is.EqualTo(DisplayStyle.None), "The closed main menu leaked into the mounted pilot UI.");
|
||||||
|
Assert.That(provider.CurrentDocument.SurfaceDocument.Surface.Root.Children
|
||||||
|
.Single(component => component.Id == "aetheria.daemon.game.frame").Layout["display"],
|
||||||
|
Is.EqualTo("none"), "Daemon diagnostics leaked into the pilot UI.");
|
||||||
trajectories = root.GetComponent<EveUnityShotTrajectoryRenderer>();
|
trajectories = root.GetComponent<EveUnityShotTrajectoryRenderer>();
|
||||||
Assert.That(trajectories, Is.Not.Null);
|
Assert.That(trajectories, Is.Not.Null);
|
||||||
host.ShotAvailable += shot =>
|
host.ShotAvailable += shot =>
|
||||||
|
|
@ -957,6 +960,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
Assert.That(captureFieldVolume.CompositeCount - initialCompositeCount,
|
Assert.That(captureFieldVolume.CompositeCount - initialCompositeCount,
|
||||||
Is.GreaterThanOrEqualTo(temporalSettlingFrames),
|
Is.GreaterThanOrEqualTo(temporalSettlingFrames),
|
||||||
"The pilot capture ran before the advertised temporal reconstruction had settled.");
|
"The pilot capture ran before the advertised temporal reconstruction had settled.");
|
||||||
|
Assert.That(cockpit.resolvedStyle.display, Is.Not.EqualTo(DisplayStyle.None));
|
||||||
|
Assert.That(cockpit.worldBound.width, Is.GreaterThan(0),
|
||||||
|
"The mounted provider cockpit has no visible screen layout.");
|
||||||
Debug.Log($"EveUnity witness phase temporal-settling-128-frames took {temporalSettlingElapsed.Elapsed.TotalMilliseconds:0.###}ms.");
|
Debug.Log($"EveUnity witness phase temporal-settling-128-frames took {temporalSettlingElapsed.Elapsed.TotalMilliseconds:0.###}ms.");
|
||||||
var pilotCaptureElapsed = Stopwatch.StartNew();
|
var pilotCaptureElapsed = Stopwatch.StartNew();
|
||||||
yield return null;
|
yield return null;
|
||||||
|
|
@ -1201,8 +1207,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
|
|
||||||
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
|
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
|
||||||
string rendezvousEndpoint,
|
string rendezvousEndpoint,
|
||||||
string verseId,
|
string verseId)
|
||||||
string authorityRuntimeId)
|
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
using var client = new CultMeshClient(new CultMeshClientOptions
|
using var client = new CultMeshClient(new CultMeshClientOptions
|
||||||
|
|
@ -1213,9 +1218,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
new CultMeshNativeQuicRealtimeTransportConnector()
|
new CultMeshNativeQuicRealtimeTransportConnector()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
using var session = await client.ConnectRealtimeAsync(
|
using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token);
|
||||||
new CultMeshSessionTarget(verseId, authorityRuntimeId),
|
|
||||||
timeout.Token);
|
|
||||||
return await session.ReceiveAsync(timeout.Token);
|
return await session.ReceiveAsync(timeout.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@
|
||||||
"com.unity.modules.physics": "1.0.0",
|
"com.unity.modules.physics": "1.0.0",
|
||||||
"com.unity.modules.uielements": "1.0.0",
|
"com.unity.modules.uielements": "1.0.0",
|
||||||
"com.unity.render-pipelines.universal": "17.4.0",
|
"com.unity.render-pipelines.universal": "17.4.0",
|
||||||
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
|
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.43",
|
||||||
"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.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
|
||||||
"org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
|
"org.gamecult.eve.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#d1663b36d12b60ff5c7249135eff48edf6227ca9",
|
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.103",
|
||||||
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2"
|
||||||
},
|
},
|
||||||
"testables": [
|
"testables": [
|
||||||
"org.gamecult.eve.unity-scene",
|
"org.gamecult.eve.unity-scene",
|
||||||
|
|
|
||||||
|
|
@ -119,11 +119,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org.gamecult.cultlib": {
|
"org.gamecult.cultlib": {
|
||||||
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
|
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.43",
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "git",
|
"source": "git",
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e"
|
"hash": "f67f5122ed1bd11da016e7b820ed60145ccd0299"
|
||||||
},
|
},
|
||||||
"org.gamecult.eve.plugin-fields": {
|
"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",
|
"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"
|
"hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
|
||||||
},
|
},
|
||||||
"org.gamecult.eve.surface": {
|
"org.gamecult.eve.surface": {
|
||||||
"version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
|
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4",
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "git",
|
"source": "git",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.cultlib": "1.0.56"
|
"org.gamecult.cultlib": "1.0.8"
|
||||||
},
|
},
|
||||||
"hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825"
|
"hash": "e08fa08335f99e9edddeb706912eecfad07cb281"
|
||||||
},
|
},
|
||||||
"org.gamecult.eve.unity-scene": {
|
"org.gamecult.eve.unity-scene": {
|
||||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9",
|
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.103",
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "git",
|
"source": "git",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.cultlib": "1.0.56",
|
"org.gamecult.cultlib": "1.0.43",
|
||||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||||
"org.gamecult.eve.surface": "0.3.6",
|
"org.gamecult.eve.surface": "0.2.4",
|
||||||
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
|
"org.gamecult.eve.unity-uitoolkit": "0.1.2",
|
||||||
"com.unity.mathematics": "1.3.2",
|
"com.unity.mathematics": "1.3.2",
|
||||||
"com.unity.render-pipelines.universal": "17.4.0",
|
"com.unity.render-pipelines.universal": "17.4.0",
|
||||||
"com.unity.modules.particlesystem": "1.0.0"
|
"com.unity.modules.particlesystem": "1.0.0"
|
||||||
},
|
},
|
||||||
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
"hash": "050b2019bd6faa4275013516870d1ded245ee391"
|
||||||
},
|
},
|
||||||
"org.gamecult.eve.unity-uitoolkit": {
|
"org.gamecult.eve.unity-uitoolkit": {
|
||||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9",
|
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2",
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "git",
|
"source": "git",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.eve.surface": "0.3.6"
|
"org.gamecult.eve.surface": "0.2.4"
|
||||||
},
|
},
|
||||||
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
"hash": "44390549775ec535ee31fc60a5bc594f372f4147"
|
||||||
},
|
},
|
||||||
"com.unity.modules.assetbundle": {
|
"com.unity.modules.assetbundle": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
|
|
||||||
var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "";
|
var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "";
|
||||||
var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "";
|
var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "";
|
||||||
var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ??
|
var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ??
|
||||||
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}");
|
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc");
|
||||||
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ??
|
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ??
|
||||||
Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png");
|
Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png");
|
||||||
var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ??
|
var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ??
|
||||||
|
|
@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
provider = root.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
|
provider = root.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
|
||||||
provider.Configure(
|
provider.Configure(
|
||||||
rendezvousEndpoint,
|
rendezvousEndpoint,
|
||||||
cacheDirectory,
|
replicaPath,
|
||||||
providerId,
|
providerId,
|
||||||
surfaceId,
|
surfaceId,
|
||||||
requiredSurfaceKind: "interactive-world",
|
requiredSurfaceKind: "interactive-world",
|
||||||
|
|
@ -310,8 +310,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
|
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
|
||||||
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
|
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
|
||||||
rendezvousEndpoint,
|
rendezvousEndpoint,
|
||||||
provider.Selection.VerseId,
|
provider.Selection.VerseId);
|
||||||
provider.Selection.AuthorityRuntimeId);
|
|
||||||
var quicDeadline = Time.realtimeSinceStartup + 15f;
|
var quicDeadline = Time.realtimeSinceStartup + 15f;
|
||||||
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
|
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
|
||||||
yield return new WaitForSecondsRealtime(0.05f);
|
yield return new WaitForSecondsRealtime(0.05f);
|
||||||
|
|
@ -332,15 +331,19 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
provider,
|
provider,
|
||||||
provider);
|
provider);
|
||||||
host.Connect();
|
host.Connect();
|
||||||
var loweredSurface = new EveUiToolkitSurfaceLowerer().Lower(
|
var mountedOverlay = root.GetComponent<EveUnityUiToolkitOverlay>();
|
||||||
provider.CurrentDocument.SurfaceDocument,
|
Assert.That(mountedOverlay, Is.Not.Null);
|
||||||
provider.Submit);
|
var cockpit = mountedOverlay.Root.Q<VisualElement>("aetheria.daemon.game.cockpit");
|
||||||
var cockpit = loweredSurface.Q<VisualElement>("aetheria.daemon.game.cockpit");
|
|
||||||
Assert.That(cockpit, Is.Not.Null, "The provider did not publish a generic cockpit overlay.");
|
Assert.That(cockpit, Is.Not.Null, "The provider did not publish a generic cockpit overlay.");
|
||||||
cockpitProgressCount = cockpit.Query<ProgressBar>().ToList().Count;
|
cockpitProgressCount = cockpit.Query<ProgressBar>().ToList().Count;
|
||||||
Assert.That(cockpitProgressCount, Is.GreaterThanOrEqualTo(7));
|
Assert.That(cockpitProgressCount, Is.GreaterThanOrEqualTo(7));
|
||||||
Assert.That(loweredSurface.Q<VisualElement>("aetheria.daemon.game.frame").style.display.value,
|
Assert.That(mountedOverlay.Root.Q<VisualElement>("aetheria.daemon.game.frame").style.display.value,
|
||||||
Is.EqualTo(DisplayStyle.None), "Daemon diagnostics leaked into the pilot UI.");
|
Is.EqualTo(DisplayStyle.None), "Daemon diagnostics leaked into the mounted pilot UI.");
|
||||||
|
Assert.That(mountedOverlay.Root.Q<VisualElement>("aetheria.daemon.game.main_menu").style.display.value,
|
||||||
|
Is.EqualTo(DisplayStyle.None), "The closed main menu leaked into the mounted pilot UI.");
|
||||||
|
Assert.That(provider.CurrentDocument.SurfaceDocument.Surface.Root.Children
|
||||||
|
.Single(component => component.Id == "aetheria.daemon.game.frame").Layout["display"],
|
||||||
|
Is.EqualTo("none"), "Daemon diagnostics leaked into the pilot UI.");
|
||||||
trajectories = root.GetComponent<EveUnityShotTrajectoryRenderer>();
|
trajectories = root.GetComponent<EveUnityShotTrajectoryRenderer>();
|
||||||
Assert.That(trajectories, Is.Not.Null);
|
Assert.That(trajectories, Is.Not.Null);
|
||||||
host.ShotAvailable += shot =>
|
host.ShotAvailable += shot =>
|
||||||
|
|
@ -957,6 +960,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
Assert.That(captureFieldVolume.CompositeCount - initialCompositeCount,
|
Assert.That(captureFieldVolume.CompositeCount - initialCompositeCount,
|
||||||
Is.GreaterThanOrEqualTo(temporalSettlingFrames),
|
Is.GreaterThanOrEqualTo(temporalSettlingFrames),
|
||||||
"The pilot capture ran before the advertised temporal reconstruction had settled.");
|
"The pilot capture ran before the advertised temporal reconstruction had settled.");
|
||||||
|
Assert.That(cockpit.resolvedStyle.display, Is.Not.EqualTo(DisplayStyle.None));
|
||||||
|
Assert.That(cockpit.worldBound.width, Is.GreaterThan(0),
|
||||||
|
"The mounted provider cockpit has no visible screen layout.");
|
||||||
Debug.Log($"EveUnity witness phase temporal-settling-128-frames took {temporalSettlingElapsed.Elapsed.TotalMilliseconds:0.###}ms.");
|
Debug.Log($"EveUnity witness phase temporal-settling-128-frames took {temporalSettlingElapsed.Elapsed.TotalMilliseconds:0.###}ms.");
|
||||||
var pilotCaptureElapsed = Stopwatch.StartNew();
|
var pilotCaptureElapsed = Stopwatch.StartNew();
|
||||||
yield return null;
|
yield return null;
|
||||||
|
|
@ -1201,8 +1207,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
|
|
||||||
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
|
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
|
||||||
string rendezvousEndpoint,
|
string rendezvousEndpoint,
|
||||||
string verseId,
|
string verseId)
|
||||||
string authorityRuntimeId)
|
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
using var client = new CultMeshClient(new CultMeshClientOptions
|
using var client = new CultMeshClient(new CultMeshClientOptions
|
||||||
|
|
@ -1213,9 +1218,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
||||||
new CultMeshNativeQuicRealtimeTransportConnector()
|
new CultMeshNativeQuicRealtimeTransportConnector()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
using var session = await client.ConnectRealtimeAsync(
|
using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token);
|
||||||
new CultMeshSessionTarget(verseId, authorityRuntimeId),
|
|
||||||
timeout.Token);
|
|
||||||
return await session.ReceiveAsync(timeout.Token);
|
return await session.ReceiveAsync(timeout.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"com.unity.modules.uielements": "1.0.0",
|
"com.unity.modules.uielements": "1.0.0",
|
||||||
"org.gamecult.cultlib": "file:../../artifacts/test-dependencies/org.gamecult.cultlib",
|
"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.plugin-fields": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields",
|
||||||
"org.gamecult.eve.surface": "file:../../../Eve/packages/org.gamecult.eve.surface",
|
"org.gamecult.eve.surface": "file:../../packages/org.gamecult.eve.surface",
|
||||||
"org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene",
|
"org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene",
|
||||||
"org.gamecult.eve.unity-uitoolkit": "file:../../packages/org.gamecult.eve.unity-uitoolkit"
|
"org.gamecult.eve.unity-uitoolkit": "file:../../packages/org.gamecult.eve.unity-uitoolkit"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org.gamecult.eve.surface": {
|
"org.gamecult.eve.surface": {
|
||||||
"version": "file:../../../Eve/packages/org.gamecult.eve.surface",
|
"version": "file:../../packages/org.gamecult.eve.surface",
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "local",
|
"source": "local",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.cultlib": "1.0.56"
|
"org.gamecult.cultlib": "1.0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"org.gamecult.eve.unity-scene": {
|
"org.gamecult.eve.unity-scene": {
|
||||||
|
|
@ -145,10 +145,9 @@
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "local",
|
"source": "local",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.cultlib": "1.0.56",
|
"org.gamecult.cultlib": "1.0.14",
|
||||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||||
"org.gamecult.eve.surface": "0.3.6",
|
"org.gamecult.eve.surface": "0.2.2",
|
||||||
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
|
|
||||||
"com.unity.mathematics": "1.3.2",
|
"com.unity.mathematics": "1.3.2",
|
||||||
"com.unity.render-pipelines.universal": "17.4.0",
|
"com.unity.render-pipelines.universal": "17.4.0",
|
||||||
"com.unity.modules.particlesystem": "1.0.0"
|
"com.unity.modules.particlesystem": "1.0.0"
|
||||||
|
|
@ -159,7 +158,7 @@
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "local",
|
"source": "local",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.eve.surface": "0.3.6"
|
"org.gamecult.eve.surface": "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"com.unity.modules.assetbundle": {
|
"com.unity.modules.assetbundle": {
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,26 @@
|
||||||
|
|
||||||
The generic `ReleaseConsumerProject` is pinned to released Git packages:
|
The generic `ReleaseConsumerProject` is pinned to released Git packages:
|
||||||
|
|
||||||
- `org.gamecult.eve.unity-scene` `0.3.134`, commit
|
- `org.gamecult.eve.unity-scene` `0.3.100`, commit
|
||||||
`d1663b36d12b60ff5c7249135eff48edf6227ca9`, and
|
`3632bfb3354065b78a90b921d7025c4c3fa92be4`, and
|
||||||
`org.gamecult.eve.surface` `0.3.6`, commit
|
`org.gamecult.eve.surface` `0.2.4`, commit
|
||||||
`96839ad34c8d464ef622d8bbdd5d277e1ca9d825`;
|
`e08fa08335f99e9edddeb706912eecfad07cb281`;
|
||||||
- `org.gamecult.eve.plugin-fields` `0.2.3`, commit
|
- `org.gamecult.eve.plugin-fields` `0.2.3`, commit
|
||||||
`c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`;
|
`c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`;
|
||||||
- `org.gamecult.eve.unity-uitoolkit` `0.1.9`, commit
|
- `org.gamecult.eve.unity-uitoolkit` `0.1.1`, commit
|
||||||
`d1663b36d12b60ff5c7249135eff48edf6227ca9`;
|
`4d0cbe0185bdc4fc65eb63503a7c5cb578539669`;
|
||||||
- `org.gamecult.cultlib` `1.0.56`, commit
|
- `org.gamecult.cultlib` `1.0.43`, commit
|
||||||
`334e60f1928b4212a29dd8b0d19b2c099fe6365e`.
|
`f67f5122ed1bd11da016e7b820ed60145ccd0299`.
|
||||||
|
|
||||||
The hand-run integration gates are the cold lowering proof and the warm
|
The hand-run integration gates are the cold lowering proof and the warm
|
||||||
full-session proof:
|
full-session proof:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pwsh -File .\scripts\run-aetheria-daemon-world-witness.ps1 `
|
pwsh -File .\scripts\run-aetheria-daemon-world-witness.ps1 `
|
||||||
-CultLibRoot E:\Projects\CultLib `
|
-CultLibRoot E:\Projects\CultLib-codex-cultmesh-reliability `
|
||||||
-CacheState cold `
|
-CacheState cold `
|
||||||
-AssetCacheDirectory artifacts\admitted-release-cold-cache `
|
-AssetCacheDirectory artifacts\cultlib-1043-scene-03100-cold-cache `
|
||||||
-OutputDirectory artifacts\admitted-release-cold `
|
-OutputDirectory artifacts\cultlib-1043-scene-03100-cold `
|
||||||
-SkipAssetBundleBuild
|
-SkipAssetBundleBuild
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -31,13 +31,9 @@ Both connect directly to the Aetheria daemon. Control, subscriptions, commands,
|
||||||
and receipts use `cultnet+tcp`; provider files use `cultmesh-content+tcp`;
|
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
|
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
|
client receives the advertised latest-only realtime body. No Odin hop or
|
||||||
Aetheria-specific gameplay code participates in the Unity runtime. This
|
Aetheria-specific gameplay code participates in the Unity runtime.
|
||||||
admission pass proves the package graph with the isolated EditMode consumer; it
|
|
||||||
does not claim a new rendered end-to-end witness.
|
|
||||||
|
|
||||||
## Historical witness ledger
|
The current cold proof is
|
||||||
|
|
||||||
The retained cold proof is
|
|
||||||
`artifacts/cultlib-1043-scene-03100-cold5/runtime-witness.cold.json`. It began
|
`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
|
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
|
inside PlayMode). The client materialized nine requested bundles totaling
|
||||||
|
|
@ -49,7 +45,7 @@ unloaded. This cold profile owns content transfer, world lowering, fog and
|
||||||
Stardust execution, and pilot/map camera isolation. It deliberately issues no
|
Stardust execution, and pilot/map camera isolation. It deliberately issues no
|
||||||
gameplay command into the paused Terminus fixture.
|
gameplay command into the paused Terminus fixture.
|
||||||
|
|
||||||
The retained warm full-session proof is
|
The current warm full-session proof is
|
||||||
`artifacts/cultlib-1043-scene-03100-warm/runtime-witness.warm.json`. It passed in
|
`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,
|
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
|
buys and sells one typed item, resumes and undocks, moves, targets, aims, holds
|
||||||
|
|
@ -67,6 +63,8 @@ visual-lever and shader-calibration issues, not concealed transport failures.
|
||||||
The map capture is clean and contains only map-channel glyphs on its dark map
|
The map capture is clean and contains only map-channel glyphs on its dark map
|
||||||
background.
|
background.
|
||||||
|
|
||||||
|
## Historical witness ledger
|
||||||
|
|
||||||
An earlier released-package cold world witness passed with:
|
An earlier released-package cold world witness passed with:
|
||||||
|
|
||||||
- `org.gamecult.eve.unity-scene` `0.3.63`, commit
|
- `org.gamecult.eve.unity-scene` `0.3.63`, commit
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ importing provider product code into the generic runtime.
|
||||||
|
|
||||||
EveUnity owns Unity scene projection, `GameObject` lifecycle, camera and input
|
EveUnity owns Unity scene projection, `GameObject` lifecycle, camera and input
|
||||||
drivers, asset-provider hooks, package release, runtime tests, and captures.
|
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
|
## Inputs
|
||||||
|
|
||||||
|
|
@ -43,10 +40,6 @@ presentation counters, and receipt display state are projections. They are not
|
||||||
world truth. Temporal targets reset when the volume program, node, or render size
|
world truth. Temporal targets reset when the volume program, node, or render size
|
||||||
changes and can never become provider state.
|
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
|
Camera-relative field viewports have one generic resolver. The advertised
|
||||||
viewport frame owns its extent, spatial-cell scale, raster texels per cell, and
|
viewport frame owns its extent, spatial-cell scale, raster texels per cell, and
|
||||||
snap policy; fog volumes and stateless particle programs consume the identical
|
snap policy; fog volumes and stateless particle programs consume the identical
|
||||||
|
|
@ -72,18 +65,6 @@ temporal pass must also advertise its current-sample, history,
|
||||||
previous-view-projection, and reset-history ports; partial temporal programs fail
|
previous-view-projection, and reset-history ports; partial temporal programs fail
|
||||||
closed.
|
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
|
Viewport-sized shader inputs are likewise presentation state. A provider may
|
||||||
relate a logical vector port to a logical native texture port through
|
relate a logical vector port to a logical native texture port through
|
||||||
`viewportTextureScaleBindings`; EveUnity derives the vector from the active
|
`viewportTextureScaleBindings`; EveUnity derives the vector from the active
|
||||||
|
|
@ -105,14 +86,11 @@ modes into one camera opinion or infer a product camera from entity kind.
|
||||||
|
|
||||||
## Shared Paths
|
## Shared Paths
|
||||||
|
|
||||||
Initial preparation asks one rendezvous for Verses, selects an advertised
|
Initial connect discovers a Verse endpoint and selects a provider surface by
|
||||||
authority runtime and provider surface by semantic kind, then leases it through
|
semantic kind. Refresh, reconnect, and terminal-receipt reconciliation consume
|
||||||
the explicit Verse/authority-runtime target. The Eve provider ID remains a
|
that provider's surface documents. Movement, focus, target, and action input all
|
||||||
separate surface-owner identity.
|
become `gamecult.eve.command_invocation.v1` through the provider-advertised
|
||||||
Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain
|
command boundary.
|
||||||
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
|
## Cut Line
|
||||||
|
|
||||||
|
|
|
||||||
7
packages/org.gamecult.eve.surface/Directory.Build.props
Normal file
7
packages/org.gamecult.eve.surface/Directory.Build.props
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<BaseOutputPath>$(MSBuildThisFileDirectory)bin~\</BaseOutputPath>
|
||||||
|
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)obj~\</BaseIntermediateOutputPath>
|
||||||
|
<MSBuildProjectExtensionsPath>$(MSBuildThisFileDirectory)obj~\</MSBuildProjectExtensionsPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Description>Portable Eve surface and provider contracts.</Description>
|
||||||
|
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||||
|
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ce2d4913d64690c44ae68935014fd79d
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
29
packages/org.gamecult.eve.surface/README.md
Normal file
29
packages/org.gamecult.eve.surface/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 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.
|
||||||
7
packages/org.gamecult.eve.surface/README.md.meta
Normal file
7
packages/org.gamecult.eve.surface/README.md.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fe5aa27636bead047b2aecb14fdc59f2
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
packages/org.gamecult.eve.surface/Runtime.meta
Normal file
8
packages/org.gamecult.eve.surface/Runtime.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2164f6fea1216214d895e7dc99c46ecf
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
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<EveAssetCatalogEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> metadata)
|
||||||
|
{
|
||||||
|
AssetRef = assetRef ?? "";
|
||||||
|
AssetKind = assetKind ?? "";
|
||||||
|
Variants = variants ?? Array.Empty<EveAssetVariant>();
|
||||||
|
Metadata = metadata == null
|
||||||
|
? new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
: new Dictionary<string, string>(metadata, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveAssetCatalogEntry(
|
||||||
|
string assetRef,
|
||||||
|
string assetKind,
|
||||||
|
EveAssetVariant[] variants,
|
||||||
|
IReadOnlyDictionary<string, string>? metadata = null)
|
||||||
|
: this(
|
||||||
|
assetRef,
|
||||||
|
assetKind,
|
||||||
|
variants,
|
||||||
|
metadata == null ? new Dictionary<string, string>() : new Dictionary<string, string>(metadata))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Key(0)] public string AssetRef { get; }
|
||||||
|
[Key(1)] public string AssetKind { get; }
|
||||||
|
[Key(2)] public EveAssetVariant[] Variants { get; }
|
||||||
|
[Key(3)] public Dictionary<string, string> 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<string, string> metadata)
|
||||||
|
{
|
||||||
|
RuntimeId = runtimeId ?? "";
|
||||||
|
Platform = platform ?? "";
|
||||||
|
Format = format ?? "";
|
||||||
|
Uri = uri ?? "";
|
||||||
|
ContentHash = contentHash ?? "";
|
||||||
|
SizeBytes = sizeBytes;
|
||||||
|
AssetKey = assetKey ?? "";
|
||||||
|
Metadata = metadata == null
|
||||||
|
? new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
: new Dictionary<string, string>(metadata, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveAssetVariant(
|
||||||
|
string runtimeId,
|
||||||
|
string platform,
|
||||||
|
string format,
|
||||||
|
string uri,
|
||||||
|
string contentHash,
|
||||||
|
long sizeBytes,
|
||||||
|
string assetKey,
|
||||||
|
IReadOnlyDictionary<string, string>? metadata = null)
|
||||||
|
: this(
|
||||||
|
runtimeId,
|
||||||
|
platform,
|
||||||
|
format,
|
||||||
|
uri,
|
||||||
|
contentHash,
|
||||||
|
sizeBytes,
|
||||||
|
assetKey,
|
||||||
|
metadata == null ? new Dictionary<string, string>() : new Dictionary<string, string>(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<string, string> Metadata { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8f53654f7c98d894588127ba001f11b4
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
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<EveEntitySoaBuffer>();
|
||||||
|
[Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty<EveEntitySoaColumn>();
|
||||||
|
[Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty<EveEntitySoaDirtyRange>();
|
||||||
|
[Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty<EveEntityRenderGroup>();
|
||||||
|
[Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty<EveEntityIdentity>();
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8e87e1be783846508efea45787f2d45c
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
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<EveInputActionDocument>();
|
||||||
|
[Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty<EveInputProfileDocument>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<string, string> Payload { get; set; } = new Dictionary<string, string>(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<string>();
|
||||||
|
[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<EveInputBindingDocument>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<string>();
|
||||||
|
[Key(2)] public int MaxStepIntervalMs { get; set; } = 650;
|
||||||
|
[Key(3)] public string CompletionControl { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4c297c7dbb64a0f49a7f805e770c5e55
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
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<EveInputLayoutControl> Controls { get; set; } = Array.Empty<EveInputLayoutControl>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8a2a1258551e42a9831a3e4f9483d928
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
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<string> Controls { get; set; } = Array.Empty<string>();
|
||||||
|
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<EveInputBinding> _bindings;
|
||||||
|
private readonly HashSet<string> _pressed = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, SequenceState> _sequences = new Dictionary<string, SequenceState>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public EveInputGestureResolver(IEnumerable<EveInputBinding> bindings)
|
||||||
|
{
|
||||||
|
_bindings = (bindings ?? Array.Empty<EveInputBinding>()).Where(binding => binding != null).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Press(string control, long timestampMs)
|
||||||
|
{
|
||||||
|
_pressed.Add(control ?? "");
|
||||||
|
var actions = new HashSet<string>(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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e3c2941be243a754092ac2543d1e6bf7
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
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<string, string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 17fd4173e6e44b4b9c503d343021ffcb
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
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<string> schemas,
|
||||||
|
IReadOnlyList<EveProviderWitness> witnesses,
|
||||||
|
IReadOnlyList<EveAdvertisedSurface> surfaces,
|
||||||
|
IReadOnlyList<EveAdvertisedCommand> commands,
|
||||||
|
IReadOnlyList<string>? 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<string> schemas,
|
||||||
|
IReadOnlyList<EveProviderWitness> witnesses,
|
||||||
|
IReadOnlyList<EveAdvertisedSurface> surfaces,
|
||||||
|
IReadOnlyList<EveAdvertisedCommand> commands,
|
||||||
|
IReadOnlyList<string>? 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<string>();
|
||||||
|
Witnesses = witnesses ?? Array.Empty<EveProviderWitness>();
|
||||||
|
Surfaces = surfaces ?? Array.Empty<EveAdvertisedSurface>();
|
||||||
|
Commands = commands ?? Array.Empty<EveAdvertisedCommand>();
|
||||||
|
AuthorizedBodyProducerIds = authorizedBodyProducerIds ?? Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<string> Schemas { get; }
|
||||||
|
[Key(10)] public IReadOnlyList<EveProviderWitness> Witnesses { get; }
|
||||||
|
[Key(11)] public IReadOnlyList<EveAdvertisedSurface> Surfaces { get; }
|
||||||
|
[Key(12)] public IReadOnlyList<EveAdvertisedCommand> Commands { get; }
|
||||||
|
[Key(13)] public IReadOnlyList<string> AuthorizedBodyProducerIds { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads canonical provider advertisements and the original Aetheria-expanded v1 layout.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EveProviderAdvertisementCompatibilityFormatter :
|
||||||
|
IMessagePackFormatter<EveProviderAdvertisementDocument?>
|
||||||
|
{
|
||||||
|
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<EveProviderFreshness>(options).Serialize(ref writer, value.Freshness, options);
|
||||||
|
Formatter<IReadOnlyList<string>>(options).Serialize(ref writer, value.Schemas, options);
|
||||||
|
Formatter<IReadOnlyList<EveProviderWitness>>(options).Serialize(ref writer, value.Witnesses, options);
|
||||||
|
Formatter<IReadOnlyList<EveAdvertisedSurface>>(options).Serialize(ref writer, value.Surfaces, options);
|
||||||
|
Formatter<IReadOnlyList<EveAdvertisedCommand>>(options).Serialize(ref writer, value.Commands, options);
|
||||||
|
Formatter<IReadOnlyList<string>>(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<EveProviderFreshness>(options).Deserialize(ref reader, options) : null;
|
||||||
|
var schemas = fields > 9 ? Formatter<IReadOnlyList<string>>(options).Deserialize(ref reader, options) : null;
|
||||||
|
var witnesses = fields > 10 ? Formatter<IReadOnlyList<EveProviderWitness>>(options).Deserialize(ref reader, options) : null;
|
||||||
|
var surfaces = fields > 11 ? Formatter<IReadOnlyList<EveAdvertisedSurface>>(options).Deserialize(ref reader, options) : null;
|
||||||
|
var commands = fields > 12 ? Formatter<IReadOnlyList<EveAdvertisedCommand>>(options).Deserialize(ref reader, options) : null;
|
||||||
|
var authorizedBodyProducerIds = fields > 13 ? Formatter<IReadOnlyList<string>>(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<string>(),
|
||||||
|
witnesses ?? Array.Empty<EveProviderWitness>(),
|
||||||
|
surfaces ?? Array.Empty<EveAdvertisedSurface>(),
|
||||||
|
commands ?? Array.Empty<EveAdvertisedCommand>(),
|
||||||
|
authorizedBodyProducerIds ?? Array.Empty<string>());
|
||||||
|
}
|
||||||
|
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<EveProviderFreshness>(options).Deserialize(ref reader, options)
|
||||||
|
?? new EveProviderFreshness("unknown", "", 0);
|
||||||
|
var schemas = Formatter<IReadOnlyList<string>>(options).Deserialize(ref reader, options)
|
||||||
|
?? Array.Empty<string>();
|
||||||
|
var witnesses = Formatter<IReadOnlyList<EveProviderWitness>>(options).Deserialize(ref reader, options)
|
||||||
|
?? Array.Empty<EveProviderWitness>();
|
||||||
|
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<T> Formatter<T>(MessagePackSerializerOptions options) =>
|
||||||
|
options.Resolver.GetFormatter<T>()
|
||||||
|
?? 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<string> stateSchemas,
|
||||||
|
string commandBoundary,
|
||||||
|
string commandRecordRef,
|
||||||
|
string receiptSchema,
|
||||||
|
string receiptRecordRef,
|
||||||
|
string assetManifestRecordRef,
|
||||||
|
IReadOnlyList<string> loweringTargets,
|
||||||
|
string ownership)
|
||||||
|
{
|
||||||
|
ProjectionKind = projectionKind ?? "";
|
||||||
|
StateSchemas = stateSchemas ?? Array.Empty<string>();
|
||||||
|
CommandBoundary = commandBoundary ?? "";
|
||||||
|
CommandRecordRef = commandRecordRef ?? "";
|
||||||
|
ReceiptSchema = receiptSchema ?? "";
|
||||||
|
ReceiptRecordRef = receiptRecordRef ?? "";
|
||||||
|
AssetManifestRecordRef = assetManifestRecordRef ?? "";
|
||||||
|
LoweringTargets = loweringTargets ?? Array.Empty<string>();
|
||||||
|
Ownership = ownership ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Key(0)] public string ProjectionKind { get; }
|
||||||
|
[Key(1)] public IReadOnlyList<string> 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<string> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f7ea0cd07c4d5da4099b5acf8d44ca2e
|
||||||
874
packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs
Normal file
874
packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs
Normal file
|
|
@ -0,0 +1,874 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using GameCult.Mesh;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace GameCult.Eve.Surface
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EveSurfaceBuilder
|
||||||
|
{
|
||||||
|
private readonly string _surfaceId;
|
||||||
|
private readonly List<EveSurfaceComponent> _children = new();
|
||||||
|
private readonly List<EveStyleToken> _styles = new();
|
||||||
|
private readonly List<EveCommandTemplate> _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<EveSurfaceGroupBuilder> build)
|
||||||
|
{
|
||||||
|
return Group(id, "column", build);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceBuilder ButtonRow(string id, Action<EveSurfaceGroupBuilder> build)
|
||||||
|
{
|
||||||
|
return Group(id, "row", build);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceBuilder Form(string id, Action<EveSurfaceFormBuilder> 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<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_root = BuildContainer(id, "partition", Props(("split", "y")), build);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceBuilder RootRow(string id, Action<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_root = BuildContainer(id, "partition", Props(("split", "x")), build);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceBuilder RootGrid(
|
||||||
|
string id,
|
||||||
|
int columns,
|
||||||
|
int rows,
|
||||||
|
Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_children.Add(BuildContainer(id, "partition", Props(("split", "x")), build));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceBuilder Column(string id, Action<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_children.Add(BuildContainer(id, "partition", Props(("split", "y")), build));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceBuilder Grid(string id, int columns, int rows, Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder> 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<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["slotId"] = slotId ?? "",
|
||||||
|
["documentId"] = documentId ?? "",
|
||||||
|
["schemaId"] = schemaId ?? "",
|
||||||
|
["presentationKind"] = presentationKind ?? ""
|
||||||
|
},
|
||||||
|
Array.Empty<EveSurfaceComponent>(),
|
||||||
|
Array.Empty<CultMeshStateBindingDescriptor>(),
|
||||||
|
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<EveSurfaceGroupBuilder> 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<string, string> props,
|
||||||
|
Action<EveSurfaceContainerBuilder> 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<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["value"] = value ?? ""
|
||||||
|
},
|
||||||
|
Array.Empty<EveSurfaceComponent>());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static EveSurfaceComponent ButtonComponent(
|
||||||
|
string id,
|
||||||
|
string label,
|
||||||
|
CultMeshOperationBindingDescriptor operation)
|
||||||
|
{
|
||||||
|
return new EveSurfaceComponent(
|
||||||
|
id,
|
||||||
|
"control.button",
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["label"] = label ?? operation.Label,
|
||||||
|
["command"] = operation.OperationId,
|
||||||
|
["operationId"] = operation.OperationId,
|
||||||
|
["schemaId"] = operation.SchemaId
|
||||||
|
},
|
||||||
|
Array.Empty<EveSurfaceComponent>());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Dictionary<string, string> EmptyProps()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Dictionary<string, string> 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<string, string> _props;
|
||||||
|
private readonly Action<EveCommandTemplate> _addCommand;
|
||||||
|
private readonly List<EveSurfaceComponent> _children = new();
|
||||||
|
private readonly List<CultMeshStateBindingDescriptor> _stateBindings = new();
|
||||||
|
private readonly List<EveEmbeddedDocumentSlot> _embeddedDocuments = new();
|
||||||
|
|
||||||
|
internal EveSurfaceContainerBuilder(
|
||||||
|
string id,
|
||||||
|
string kind,
|
||||||
|
IReadOnlyDictionary<string, string> props,
|
||||||
|
Action<EveCommandTemplate> addCommand)
|
||||||
|
{
|
||||||
|
_id = id ?? "";
|
||||||
|
_kind = kind ?? "partition";
|
||||||
|
_props = new Dictionary<string, string>(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<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceContainerBuilder Column(string id, Action<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceContainerBuilder Grid(
|
||||||
|
string id,
|
||||||
|
int columns,
|
||||||
|
int rows,
|
||||||
|
Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceContainerBuilder Card(string id, string title, Action<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceContainerBuilder Modal(string id, string title, Action<EveSurfaceContainerBuilder> build)
|
||||||
|
{
|
||||||
|
_children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceContainerBuilder FieldRow(
|
||||||
|
string id,
|
||||||
|
string label,
|
||||||
|
Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder>? 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<EveSurfaceContainerBuilder> 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<EveSurfaceComponent>(),
|
||||||
|
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<EveSurfaceComponent>(),
|
||||||
|
Array.Empty<CultMeshStateBindingDescriptor>(),
|
||||||
|
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<string, string> props,
|
||||||
|
CultMeshStateBindingDescriptor? binding = null,
|
||||||
|
IReadOnlyList<EveSurfaceComponent>? children = null,
|
||||||
|
Action<EveSurfaceContainerBuilder>? configure = null)
|
||||||
|
{
|
||||||
|
var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand);
|
||||||
|
configure?.Invoke(builder);
|
||||||
|
var component = new EveSurfaceComponent(
|
||||||
|
id ?? "",
|
||||||
|
kind ?? "",
|
||||||
|
new Dictionary<string, string>(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal),
|
||||||
|
children ?? Array.Empty<EveSurfaceComponent>(),
|
||||||
|
binding == null ? Array.Empty<CultMeshStateBindingDescriptor>() : new[] { binding });
|
||||||
|
if (builder._props.Count != (props?.Count ?? 0))
|
||||||
|
{
|
||||||
|
component = new EveSurfaceComponent(
|
||||||
|
id ?? "",
|
||||||
|
kind ?? "",
|
||||||
|
new Dictionary<string, string>(builder._props, StringComparer.Ordinal),
|
||||||
|
children ?? Array.Empty<EveSurfaceComponent>(),
|
||||||
|
binding == null ? Array.Empty<CultMeshStateBindingDescriptor>() : 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<string, string> props,
|
||||||
|
Action<EveSurfaceContainerBuilder> 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<EveCommandTemplate> _addCommand;
|
||||||
|
private readonly List<EveSurfaceComponent> _children = new();
|
||||||
|
|
||||||
|
internal EveSurfaceGroupBuilder(
|
||||||
|
string id,
|
||||||
|
string kind,
|
||||||
|
Action<EveCommandTemplate> 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<EveCommandTemplate> _addCommand;
|
||||||
|
private readonly List<EveSurfaceComponent> _children = new();
|
||||||
|
|
||||||
|
internal EveSurfaceFormBuilder(string id, Action<EveCommandTemplate> 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<CultMeshStateBindingDescriptor>()
|
||||||
|
: new[] { binding };
|
||||||
|
_children.Add(new EveSurfaceComponent(
|
||||||
|
$"{_id}.metric.{EveSurfaceBuilder.Slug(label)}",
|
||||||
|
"metric",
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["label"] = label ?? "",
|
||||||
|
["value"] = value ?? ""
|
||||||
|
},
|
||||||
|
Array.Empty<EveSurfaceComponent>(),
|
||||||
|
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<CultMeshStateBindingDescriptor>()
|
||||||
|
: new[] { binding };
|
||||||
|
return new EveSurfaceComponent(
|
||||||
|
$"{_id}.{EveSurfaceBuilder.Slug(label)}",
|
||||||
|
kind,
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["label"] = label ?? "",
|
||||||
|
["value"] = value ?? "",
|
||||||
|
["command"] = operation.OperationId,
|
||||||
|
["operationId"] = operation.OperationId,
|
||||||
|
["schemaId"] = operation.SchemaId
|
||||||
|
},
|
||||||
|
Array.Empty<EveSurfaceComponent>(),
|
||||||
|
bindings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class EveSurface
|
||||||
|
{
|
||||||
|
public static EveSurfaceBuilder Create(string surfaceId)
|
||||||
|
{
|
||||||
|
return new EveSurfaceBuilder(surfaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 97ac7f757692aef4db64d75d0df6fe51
|
||||||
538
packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs
Normal file
538
packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
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<EveCommandTemplate> 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<EveCommandTemplate> 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<EveCommandTemplate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<EveCommandTemplate> Commands { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class EveSurfaceTree
|
||||||
|
{
|
||||||
|
[SerializationConstructor]
|
||||||
|
public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList<EveStyleToken> styles)
|
||||||
|
{
|
||||||
|
Id = id ?? "";
|
||||||
|
Root = root ?? throw new ArgumentNullException(nameof(root));
|
||||||
|
Styles = styles ?? Array.Empty<EveStyleToken>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Key(0)]
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
[Key(1)]
|
||||||
|
public EveSurfaceComponent Root { get; }
|
||||||
|
|
||||||
|
[Key(2)]
|
||||||
|
public IReadOnlyList<EveStyleToken> Styles { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class EveSurfaceComponent
|
||||||
|
{
|
||||||
|
public EveSurfaceComponent(
|
||||||
|
string id,
|
||||||
|
string kind,
|
||||||
|
IReadOnlyDictionary<string, string> props,
|
||||||
|
IReadOnlyList<EveSurfaceComponent> children)
|
||||||
|
: this(id, kind, props, children, StateBindingsFromProps(props))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceComponent(
|
||||||
|
string id,
|
||||||
|
string kind,
|
||||||
|
IReadOnlyDictionary<string, string> props,
|
||||||
|
IReadOnlyList<EveSurfaceComponent> children,
|
||||||
|
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings)
|
||||||
|
: this(id, kind, props, children, stateBindings, Array.Empty<EveEmbeddedDocumentSlot>())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public EveSurfaceComponent(
|
||||||
|
string id,
|
||||||
|
string kind,
|
||||||
|
IReadOnlyDictionary<string, string> props,
|
||||||
|
IReadOnlyList<EveSurfaceComponent> children,
|
||||||
|
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings,
|
||||||
|
IReadOnlyList<EveEmbeddedDocumentSlot> embeddedDocuments,
|
||||||
|
IReadOnlyDictionary<string, string>? layout = null,
|
||||||
|
IReadOnlyDictionary<string, string>? style = null)
|
||||||
|
: this(
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
props,
|
||||||
|
children,
|
||||||
|
(stateBindings ?? Array.Empty<CultMeshStateBindingDescriptor>())
|
||||||
|
.Select(CultMeshStateBindingRecord.FromBinding)
|
||||||
|
.ToArray(),
|
||||||
|
embeddedDocuments,
|
||||||
|
layout,
|
||||||
|
style)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[SerializationConstructor]
|
||||||
|
public EveSurfaceComponent(
|
||||||
|
string id,
|
||||||
|
string kind,
|
||||||
|
IReadOnlyDictionary<string, string> props,
|
||||||
|
IReadOnlyList<EveSurfaceComponent> children,
|
||||||
|
CultMeshStateBindingRecord[] stateBindingRecords,
|
||||||
|
IReadOnlyList<EveEmbeddedDocumentSlot> embeddedDocuments,
|
||||||
|
IReadOnlyDictionary<string, string>? layout = null,
|
||||||
|
IReadOnlyDictionary<string, string>? style = null)
|
||||||
|
{
|
||||||
|
Id = id ?? "";
|
||||||
|
Kind = kind ?? "";
|
||||||
|
Props = props ?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
Children = children ?? Array.Empty<EveSurfaceComponent>();
|
||||||
|
StateBindingRecords = stateBindingRecords ?? Array.Empty<CultMeshStateBindingRecord>();
|
||||||
|
EmbeddedDocuments = embeddedDocuments ?? Array.Empty<EveEmbeddedDocumentSlot>();
|
||||||
|
Layout = layout ?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
Style = style ?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Key(0)]
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
[Key(1)]
|
||||||
|
public string Kind { get; }
|
||||||
|
|
||||||
|
[Key(2)]
|
||||||
|
public IReadOnlyDictionary<string, string> Props { get; }
|
||||||
|
|
||||||
|
[Key(3)]
|
||||||
|
public IReadOnlyList<EveSurfaceComponent> Children { get; }
|
||||||
|
|
||||||
|
[Key(4)]
|
||||||
|
public CultMeshStateBindingRecord[] StateBindingRecords { get; }
|
||||||
|
|
||||||
|
[IgnoreMember]
|
||||||
|
public IReadOnlyList<CultMeshStateBindingDescriptor> StateBindings =>
|
||||||
|
StateBindingRecords.Select(record => record.ToBinding()).ToArray();
|
||||||
|
|
||||||
|
[Key(5)]
|
||||||
|
public IReadOnlyList<EveEmbeddedDocumentSlot> EmbeddedDocuments { get; }
|
||||||
|
|
||||||
|
[Key(6)]
|
||||||
|
public IReadOnlyDictionary<string, string> Layout { get; }
|
||||||
|
|
||||||
|
[Key(7)]
|
||||||
|
public IReadOnlyDictionary<string, string> Style { get; }
|
||||||
|
|
||||||
|
public string GetProp(string key, string fallback = "")
|
||||||
|
{
|
||||||
|
return Props.TryGetValue(key, out var value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CultMeshStateBindingDescriptor> StateBindingsFromProps(
|
||||||
|
IReadOnlyDictionary<string, string>? props)
|
||||||
|
{
|
||||||
|
if (props == null || props.Count == 0)
|
||||||
|
return Array.Empty<CultMeshStateBindingDescriptor>();
|
||||||
|
|
||||||
|
var bindings = new List<CultMeshStateBindingDescriptor>();
|
||||||
|
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<string, string>(StringComparer.Ordinal),
|
||||||
|
issuedAt,
|
||||||
|
clientId,
|
||||||
|
commandBoundary,
|
||||||
|
receiptSchema)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[SerializationConstructor]
|
||||||
|
public EveSurfaceCommandRequest(
|
||||||
|
string schema,
|
||||||
|
string providerId,
|
||||||
|
string surfaceId,
|
||||||
|
CultMeshOperationInvocationRecord operation,
|
||||||
|
Dictionary<string, string> 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<string, string>(StringComparer.Ordinal)
|
||||||
|
: new Dictionary<string, string>(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<string, string> 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 ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the current Eve surface envelope and the original seven-field v1 envelope.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter<EveSurfaceDocument?>
|
||||||
|
{
|
||||||
|
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<EveSurfaceTree>(options).Serialize(ref writer, value.Surface, options);
|
||||||
|
Formatter<IReadOnlyList<EveCommandTemplate>>(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<EveSurfaceTree>(options).Deserialize(ref reader, options)!,
|
||||||
|
Formatter<IReadOnlyList<EveCommandTemplate>>(options).Deserialize(ref reader, options)
|
||||||
|
?? Array.Empty<EveCommandTemplate>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<EveSurfaceTree>(options).Deserialize(ref reader, options) : null;
|
||||||
|
var commands = fields > 8 ? Formatter<IReadOnlyList<EveCommandTemplate>>(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<EveCommandTemplate>());
|
||||||
|
}
|
||||||
|
finally { reader.Depth--; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IMessagePackFormatter<T> Formatter<T>(MessagePackSerializerOptions options) =>
|
||||||
|
options.Resolver.GetFormatter<T>()
|
||||||
|
?? 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) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads both direct legacy command fields and the current wrapped binding record.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter<EveCommandTemplate?>
|
||||||
|
{
|
||||||
|
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<CultMeshOperationBindingRecord> BindingFormatter(
|
||||||
|
MessagePackSerializerOptions options) =>
|
||||||
|
options.Resolver.GetFormatter<CultMeshOperationBindingRecord>()
|
||||||
|
?? 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() ?? "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6516c42104b235f42b15efeb4639bb73
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a9004278c14816345941af2f2ce673ca
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
29
packages/org.gamecult.eve.surface/package.json
Normal file
29
packages/org.gamecult.eve.surface/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
7
packages/org.gamecult.eve.surface/package.json.meta
Normal file
7
packages/org.gamecult.eve.surface/package.json.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 49f1738bd94075e489c82190cf660b1d
|
||||||
|
PackageManifestImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -16,47 +16,14 @@ 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
|
networked client. Give it one Verse rendezvous endpoint and it discovers an
|
||||||
advertised `interactive-world`, constructs the generic CultMesh transport, and
|
advertised `interactive-world`, constructs the generic CultMesh transport, and
|
||||||
provides every bootstrap port. Provider, surface, and Verse IDs are optional
|
provides every bootstrap port. Provider, surface, and Verse IDs are optional
|
||||||
selection filters; they are not product knowledge required by the client. The
|
selection filters; they are not product knowledge required by the client.
|
||||||
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.
|
Await `PrepareAsync()` before mounting the bootstrap or reading provider ports.
|
||||||
It owns discovery, initial document reads, content transfer, asynchronous asset
|
Discovery and session setup never run synchronously from Unity lifecycle or
|
||||||
bundle loading, and live lease installation. Once preparation succeeds,
|
input getters, so an unavailable provider cannot stall the editor main thread.
|
||||||
`Mount()` and `Connect()` perform no remote work. Input submission only enters a
|
Live entity, field, and receipt polling likewise runs as one non-overlapping
|
||||||
bounded asynchronous outbox: one-shot commands retain their idempotency keys,
|
background operation. Unity's main thread only applies completed typed
|
||||||
while movement and look values coalesce to the latest value per entity. A
|
documents, keeping Play, Pause, inspection, and rendering responsive while a
|
||||||
successful send is not completion: the command remains pending and retries with
|
provider is slow or temporarily unreachable.
|
||||||
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.
|
The package never imports provider product assemblies.
|
||||||
|
|
||||||
|
|
@ -176,13 +143,3 @@ authority.
|
||||||
Native volume programs may advertise a `cameraToWorld` matrix port when their
|
Native volume programs may advertise a `cameraToWorld` matrix port when their
|
||||||
procedural passes require an explicit camera transform instead of relying on
|
procedural passes require an explicit camera transform instead of relying on
|
||||||
render-pipeline globals.
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GameCult.Eve.Surface;
|
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace GameCult.Eve.UnityScene
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class EveUnityCultMeshCommandOutbox : IDisposable
|
|
||||||
{
|
|
||||||
internal enum DeliveryState
|
|
||||||
{
|
|
||||||
Queued,
|
|
||||||
Sending,
|
|
||||||
AwaitingCanonicalReceipt
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly object _gate = new object();
|
|
||||||
private readonly List<OutboxItem> _items = new List<OutboxItem>();
|
|
||||||
private readonly Dictionary<string, OutboxItem> _byCommandId =
|
|
||||||
new Dictionary<string, OutboxItem>(StringComparer.Ordinal);
|
|
||||||
private readonly Dictionary<string, OutboxItem> _continuous =
|
|
||||||
new Dictionary<string, OutboxItem>(StringComparer.Ordinal);
|
|
||||||
private readonly Func<EveSurfaceCommandRequest, CancellationToken, Task> _send;
|
|
||||||
private readonly Func<EveSurfaceCommandRequest, string?> _continuousKey;
|
|
||||||
private readonly Action<string> _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<EveSurfaceCommandRequest, CancellationToken, Task> send,
|
|
||||||
Func<EveSurfaceCommandRequest, string?> continuousKey,
|
|
||||||
Action<string>? 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Completes a command only when its canonical provider receipt arrives.</summary>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
fileFormatVersion: 2
|
|
||||||
guid: 61d76e98f2054d86bf8e7480d4411c39
|
|
||||||
timeCreated: 1787039650
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
using System;
|
|
||||||
using GameCult.Mesh;
|
|
||||||
using GameCult.Networking;
|
|
||||||
using GameCult.Networking.WebSockets;
|
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace GameCult.Eve.UnityScene
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
fileFormatVersion: 2
|
|
||||||
guid: 7f7cb06716fd4f319ade147a69acb74a
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,8 +2,6 @@ using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Stopwatch = System.Diagnostics.Stopwatch;
|
using Stopwatch = System.Diagnostics.Stopwatch;
|
||||||
using GameCult.Eve.Surface;
|
using GameCult.Eve.Surface;
|
||||||
|
|
@ -11,7 +9,6 @@ using GameCult.Eve.PluginFields;
|
||||||
using GameCult.Eve.UnityScene.Fields;
|
using GameCult.Eve.UnityScene.Fields;
|
||||||
using GameCult.Mesh;
|
using GameCult.Mesh;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Serialization;
|
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
|
|
@ -29,29 +26,19 @@ namespace GameCult.Eve.UnityScene
|
||||||
IEveUnityCameraRenderPolicySource,
|
IEveUnityCameraRenderPolicySource,
|
||||||
IEveUnityNativeAssetProvider,
|
IEveUnityNativeAssetProvider,
|
||||||
IEveUnityNativeAssetMetadataProvider,
|
IEveUnityNativeAssetMetadataProvider,
|
||||||
IEveUnityInputCapabilitySource,
|
IEveUnityInputCapabilitySource
|
||||||
IEveUnityNavigableProvider
|
|
||||||
{
|
{
|
||||||
[SerializeField] private string rendezvousEndpoint = "";
|
[SerializeField] private string rendezvousEndpoint = "";
|
||||||
[SerializeField] private string providerFilter = "";
|
[SerializeField] private string providerFilter = "";
|
||||||
[SerializeField] private string surfaceFilter = "";
|
[SerializeField] private string surfaceFilter = "";
|
||||||
[SerializeField] private string verseFilter = "";
|
[SerializeField] private string verseFilter = "";
|
||||||
[SerializeField] private string surfaceKind = "interactive-world";
|
[SerializeField] private string surfaceKind = "interactive-world";
|
||||||
[FormerlySerializedAs("replicaPath")]
|
[SerializeField] private string replicaPath = "";
|
||||||
[SerializeField] private string cacheDirectory = "";
|
|
||||||
[SerializeField] private string runtimeId = "eve-unity";
|
[SerializeField] private string runtimeId = "eve-unity";
|
||||||
private CultMeshAuthorityTrustPolicy _authorityTrust = new CultMeshAuthorityTrustPolicy(
|
|
||||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
|
||||||
private CultMeshAuthorityTrustPolicy _navigationAuthorityTrust = new CultMeshAuthorityTrustPolicy(
|
|
||||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
|
||||||
|
|
||||||
private EveUnityCultMeshLiveProviderTransport? _transport;
|
private EveUnityCultMeshLiveProviderTransport? _transport;
|
||||||
private EveUnitySceneLiveProviderBridge? _bridge;
|
private EveUnitySceneLiveProviderBridge? _bridge;
|
||||||
private PreviousProvider? _previousProvider;
|
|
||||||
private bool _navigationRouteCommitted;
|
|
||||||
private int _connectionLeaseCount;
|
|
||||||
private Task? _preparation;
|
private Task? _preparation;
|
||||||
private CancellationTokenSource? _preparationLifetime;
|
|
||||||
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
|
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
|
||||||
private readonly ConcurrentQueue<EveFieldsSplatsDocument> _pendingFields = new ConcurrentQueue<EveFieldsSplatsDocument>();
|
private readonly ConcurrentQueue<EveFieldsSplatsDocument> _pendingFields = new ConcurrentQueue<EveFieldsSplatsDocument>();
|
||||||
|
|
||||||
|
|
@ -89,36 +76,28 @@ namespace GameCult.Eve.UnityScene
|
||||||
|
|
||||||
public void Configure(
|
public void Configure(
|
||||||
string endpoint,
|
string endpoint,
|
||||||
string localCacheDirectory = "",
|
string replicaStorePath = "",
|
||||||
string providerId = "",
|
string providerId = "",
|
||||||
string surfaceId = "",
|
string surfaceId = "",
|
||||||
string verseId = "",
|
string verseId = "",
|
||||||
string requiredSurfaceKind = "interactive-world",
|
string requiredSurfaceKind = "interactive-world",
|
||||||
string clientRuntimeId = "eve-unity",
|
string clientRuntimeId = "eve-unity")
|
||||||
CultMeshAuthorityTrustPolicy? authorityTrust = null,
|
|
||||||
CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null)
|
|
||||||
{
|
{
|
||||||
if (_bridge != null || _preparation != null)
|
if (_bridge != null || _preparation != null)
|
||||||
throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration.");
|
throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration.");
|
||||||
|
|
||||||
rendezvousEndpoint = endpoint ?? "";
|
rendezvousEndpoint = endpoint ?? "";
|
||||||
cacheDirectory = localCacheDirectory ?? "";
|
replicaPath = replicaStorePath ?? "";
|
||||||
providerFilter = providerId ?? "";
|
providerFilter = providerId ?? "";
|
||||||
surfaceFilter = surfaceId ?? "";
|
surfaceFilter = surfaceId ?? "";
|
||||||
verseFilter = verseId ?? "";
|
verseFilter = verseId ?? "";
|
||||||
surfaceKind = string.IsNullOrWhiteSpace(requiredSurfaceKind) ? "interactive-world" : requiredSurfaceKind;
|
surfaceKind = string.IsNullOrWhiteSpace(requiredSurfaceKind) ? "interactive-world" : requiredSurfaceKind;
|
||||||
runtimeId = string.IsNullOrWhiteSpace(clientRuntimeId) ? "eve-unity" : clientRuntimeId;
|
runtimeId = string.IsNullOrWhiteSpace(clientRuntimeId) ? "eve-unity" : clientRuntimeId;
|
||||||
_authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy(
|
|
||||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
|
||||||
_navigationAuthorityTrust = navigationAuthorityTrust ?? _authorityTrust;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Connect()
|
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();
|
Bridge.Connect();
|
||||||
checked { _connectionLeaseCount++; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task PrepareAsync()
|
public Task PrepareAsync()
|
||||||
|
|
@ -126,132 +105,26 @@ namespace GameCult.Eve.UnityScene
|
||||||
if (_bridge != null)
|
if (_bridge != null)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted)
|
if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted)
|
||||||
{
|
_preparation = PrepareTransportAsync();
|
||||||
_preparationLifetime?.Dispose();
|
|
||||||
_preparationLifetime = new CancellationTokenSource();
|
|
||||||
_preparation = PrepareTransportAsync(_preparationLifetime.Token);
|
|
||||||
}
|
|
||||||
return _preparation;
|
return _preparation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Disconnect()
|
public void Disconnect()
|
||||||
{
|
{
|
||||||
if (_connectionLeaseCount <= 0)
|
_bridge?.Disconnect();
|
||||||
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<string>())
|
|
||||||
.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()
|
public void Refresh()
|
||||||
{
|
{
|
||||||
var activeBridge = _navigationRouteCommitted
|
if (Bridge.IsConnected)
|
||||||
? Bridge
|
Bridge.Refresh();
|
||||||
: _previousProvider?.Bridge ?? Bridge;
|
|
||||||
if (activeBridge.IsConnected)
|
|
||||||
activeBridge.Refresh();
|
|
||||||
else
|
else
|
||||||
activeBridge.Connect();
|
Bridge.Connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Submit(EveSurfaceCommandRequest request)
|
public void Submit(EveSurfaceCommandRequest request)
|
||||||
{
|
{
|
||||||
// Navigation preparation is not a command-routing commit. The mounted
|
Bridge.Submit(request);
|
||||||
// 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)
|
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
|
||||||
|
|
@ -282,12 +155,6 @@ namespace GameCult.Eve.UnityScene
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
ReleaseTransport();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReleaseTransport()
|
|
||||||
{
|
|
||||||
_preparationLifetime?.Cancel();
|
|
||||||
if (_bridge != null)
|
if (_bridge != null)
|
||||||
{
|
{
|
||||||
_bridge.DocumentAvailable -= ForwardDocument;
|
_bridge.DocumentAvailable -= ForwardDocument;
|
||||||
|
|
@ -295,24 +162,16 @@ namespace GameCult.Eve.UnityScene
|
||||||
}
|
}
|
||||||
_bridge?.Dispose();
|
_bridge?.Dispose();
|
||||||
_transport?.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 (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||||
while (_pendingFields.TryDequeue(out _)) { }
|
while (_pendingFields.TryDequeue(out _)) { }
|
||||||
_bridge = null;
|
_bridge = null;
|
||||||
_transport = null;
|
_transport = null;
|
||||||
_preparation = null;
|
_preparation = null;
|
||||||
_preparationLifetime?.Dispose();
|
|
||||||
_preparationLifetime = null;
|
|
||||||
Selection = null;
|
Selection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
_previousProvider?.Transport?.PumpLiveEvents();
|
|
||||||
_transport?.PumpLiveEvents();
|
_transport?.PumpLiveEvents();
|
||||||
EveFieldsSplatsDocument? latestFields = null;
|
EveFieldsSplatsDocument? latestFields = null;
|
||||||
while (_pendingFields.TryDequeue(out var fields))
|
while (_pendingFields.TryDequeue(out var fields))
|
||||||
|
|
@ -340,7 +199,7 @@ namespace GameCult.Eve.UnityScene
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PrepareTransportAsync(CancellationToken cancellationToken)
|
private async Task PrepareTransportAsync()
|
||||||
{
|
{
|
||||||
var elapsed = Stopwatch.StartNew();
|
var elapsed = Stopwatch.StartNew();
|
||||||
if (_transport != null)
|
if (_transport != null)
|
||||||
|
|
@ -348,183 +207,37 @@ namespace GameCult.Eve.UnityScene
|
||||||
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
|
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
|
||||||
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
|
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
|
||||||
|
|
||||||
var prepared = await PrepareCandidateAsync(
|
Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync(
|
||||||
new[] { rendezvousEndpoint },
|
|
||||||
providerFilter,
|
|
||||||
surfaceFilter,
|
|
||||||
surfaceKind,
|
|
||||||
verseFilter,
|
|
||||||
"",
|
|
||||||
_authorityTrust,
|
|
||||||
_navigationAuthorityTrust,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
|
|
||||||
AdoptPrepared(prepared);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<PreparedProvider> PrepareCandidateAsync(
|
|
||||||
IReadOnlyList<string> endpoints,
|
|
||||||
string requiredProviderId,
|
|
||||||
string requiredSurfaceId,
|
|
||||||
string requiredSurfaceKind,
|
|
||||||
string requiredVerseId,
|
|
||||||
string requiredAuthorityRuntimeId,
|
|
||||||
CultMeshAuthorityTrustPolicy trust,
|
|
||||||
CultMeshAuthorityTrustPolicy crossTargetTrust,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var failures = new List<string>();
|
|
||||||
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,
|
rendezvousEndpoint,
|
||||||
verseFilter,
|
|
||||||
providerFilter,
|
providerFilter,
|
||||||
surfaceFilter,
|
surfaceFilter,
|
||||||
surfaceKind,
|
surfaceKind,
|
||||||
_authorityTrust);
|
verseFilter);
|
||||||
_navigationRouteCommitted = false;
|
TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
|
||||||
DetachPrepared(_transport, _bridge);
|
elapsed.Restart();
|
||||||
if (_previousProvider.Bridge != null)
|
var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath)
|
||||||
_previousProvider.Bridge.ReceiptAvailable += ForwardReceipt;
|
? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc")
|
||||||
Selection = prepared.Selection;
|
: replicaPath;
|
||||||
_transport = prepared.Transport;
|
_transport = new EveUnityCultMeshLiveProviderTransport(
|
||||||
_bridge = prepared.Bridge;
|
resolvedReplicaPath,
|
||||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
Selection.Endpoint,
|
||||||
while (_pendingFields.TryDequeue(out _)) { }
|
Selection.ProviderId,
|
||||||
|
Selection.SurfaceId,
|
||||||
|
runtimeId);
|
||||||
|
_transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
|
||||||
|
_transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields);
|
||||||
|
_bridge = new EveUnitySceneLiveProviderBridge(_transport);
|
||||||
|
_bridge.DocumentAvailable += ForwardDocument;
|
||||||
|
_bridge.ReceiptAvailable += ForwardReceipt;
|
||||||
|
TraceStartup($"transport-construction {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AttachPrepared(
|
|
||||||
EveUnityCultMeshLiveProviderTransport? transport,
|
|
||||||
EveUnitySceneLiveProviderBridge? bridge)
|
|
||||||
{
|
|
||||||
if (transport != null)
|
|
||||||
{
|
|
||||||
transport.EntityViewAvailable += QueueEntityView;
|
|
||||||
transport.FieldsSplatsAvailable += QueueFields;
|
|
||||||
}
|
|
||||||
if (bridge != null)
|
|
||||||
{
|
|
||||||
bridge.DocumentAvailable += ForwardDocument;
|
|
||||||
bridge.ReceiptAvailable += ForwardReceipt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DetachPrepared(
|
|
||||||
EveUnityCultMeshLiveProviderTransport? transport,
|
|
||||||
EveUnitySceneLiveProviderBridge? bridge)
|
|
||||||
{
|
|
||||||
if (transport != null)
|
|
||||||
{
|
|
||||||
transport.EntityViewAvailable -= QueueEntityView;
|
|
||||||
transport.FieldsSplatsAvailable -= QueueFields;
|
|
||||||
}
|
|
||||||
if (bridge == null) return;
|
|
||||||
bridge.DocumentAvailable -= ForwardDocument;
|
|
||||||
bridge.ReceiptAvailable -= ForwardReceipt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void QueueEntityView(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease) =>
|
|
||||||
_pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
|
|
||||||
|
|
||||||
private void QueueFields(EveFieldsSplatsDocument fields) => _pendingFields.Enqueue(fields);
|
|
||||||
|
|
||||||
private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) =>
|
private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) =>
|
||||||
DocumentAvailable?.Invoke(document);
|
DocumentAvailable?.Invoke(document);
|
||||||
|
|
||||||
private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) =>
|
private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) =>
|
||||||
ReceiptAvailable?.Invoke(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)
|
private static void TraceStartup(string message)
|
||||||
{
|
{
|
||||||
if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal))
|
if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal))
|
||||||
|
|
@ -549,57 +262,5 @@ namespace GameCult.Eve.UnityScene
|
||||||
public EveEntitySoaViewDocument Document { get; }
|
public EveEntitySoaViewDocument Document { get; }
|
||||||
public ICultMeshBodyReadLease Lease { 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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GameCult.Eve.Surface;
|
using GameCult.Eve.Surface;
|
||||||
using GameCult.Mesh;
|
using GameCult.Mesh;
|
||||||
|
|
@ -14,24 +13,21 @@ namespace GameCult.Eve.UnityScene
|
||||||
public sealed class EveUnityCultMeshProviderSelection
|
public sealed class EveUnityCultMeshProviderSelection
|
||||||
{
|
{
|
||||||
public EveUnityCultMeshProviderSelection(
|
public EveUnityCultMeshProviderSelection(
|
||||||
string rendezvousEndpoint,
|
string endpoint,
|
||||||
string verseId,
|
string verseId,
|
||||||
string authorityRuntimeId,
|
|
||||||
string providerId,
|
string providerId,
|
||||||
string surfaceId,
|
string surfaceId,
|
||||||
string surfaceKind)
|
string surfaceKind)
|
||||||
{
|
{
|
||||||
RendezvousEndpoint = rendezvousEndpoint ?? "";
|
Endpoint = endpoint ?? "";
|
||||||
VerseId = verseId ?? "";
|
VerseId = verseId ?? "";
|
||||||
AuthorityRuntimeId = authorityRuntimeId ?? "";
|
|
||||||
ProviderId = providerId ?? "";
|
ProviderId = providerId ?? "";
|
||||||
SurfaceId = surfaceId ?? "";
|
SurfaceId = surfaceId ?? "";
|
||||||
SurfaceKind = surfaceKind ?? "";
|
SurfaceKind = surfaceKind ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string RendezvousEndpoint { get; }
|
public string Endpoint { get; }
|
||||||
public string VerseId { get; }
|
public string VerseId { get; }
|
||||||
public string AuthorityRuntimeId { get; }
|
|
||||||
public string ProviderId { get; }
|
public string ProviderId { get; }
|
||||||
public string SurfaceId { get; }
|
public string SurfaceId { get; }
|
||||||
public string SurfaceKind { get; }
|
public string SurfaceKind { get; }
|
||||||
|
|
@ -39,22 +35,26 @@ namespace GameCult.Eve.UnityScene
|
||||||
|
|
||||||
public sealed class EveUnityCultMeshProviderDiscovery
|
public sealed class EveUnityCultMeshProviderDiscovery
|
||||||
{
|
{
|
||||||
private readonly CultMeshAuthorityTrustPolicy _authorityTrust;
|
private static readonly Type[] AdvertisementDocumentTypes =
|
||||||
|
|
||||||
public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null)
|
|
||||||
{
|
{
|
||||||
_authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy(
|
typeof(EveProviderAdvertisementDocument)
|
||||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
};
|
||||||
}
|
|
||||||
|
public EveUnityCultMeshProviderSelection Discover(
|
||||||
|
string rendezvousEndpoint,
|
||||||
|
string providerId = "",
|
||||||
|
string surfaceId = "",
|
||||||
|
string surfaceKind = "interactive-world",
|
||||||
|
string verseId = "") =>
|
||||||
|
DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
public async Task<EveUnityCultMeshProviderSelection> DiscoverAsync(
|
public async Task<EveUnityCultMeshProviderSelection> DiscoverAsync(
|
||||||
string rendezvousEndpoint,
|
string rendezvousEndpoint,
|
||||||
string providerId = "",
|
string providerId = "",
|
||||||
string surfaceId = "",
|
string surfaceId = "",
|
||||||
string surfaceKind = "interactive-world",
|
string surfaceKind = "interactive-world",
|
||||||
string verseId = "",
|
string verseId = "")
|
||||||
CancellationToken cancellationToken = default,
|
|
||||||
string requiredAuthorityRuntimeId = "")
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
|
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
|
||||||
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
|
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
|
||||||
|
|
@ -62,14 +62,13 @@ namespace GameCult.Eve.UnityScene
|
||||||
CultMeshVerseCatalogResponseMessage response;
|
CultMeshVerseCatalogResponseMessage response;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync(
|
response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync(
|
||||||
rendezvousEndpoint,
|
rendezvousEndpoint,
|
||||||
new CultMeshVerseCatalogRequestMessage
|
new CultMeshVerseCatalogRequestMessage
|
||||||
{
|
{
|
||||||
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
|
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
|
||||||
TransportVersion = "cultmesh.v0"
|
TransportVersion = "cultmesh.v0"
|
||||||
});
|
});
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
|
|
@ -81,87 +80,79 @@ namespace GameCult.Eve.UnityScene
|
||||||
var candidates = response.Verses
|
var candidates = response.Verses
|
||||||
.Where(verse => string.IsNullOrWhiteSpace(verseId) ||
|
.Where(verse => string.IsNullOrWhiteSpace(verseId) ||
|
||||||
string.Equals(verse.VerseId, verseId, StringComparison.Ordinal))
|
string.Equals(verse.VerseId, verseId, StringComparison.Ordinal))
|
||||||
.Where(verse => !string.IsNullOrWhiteSpace(verse.VerseId) &&
|
.SelectMany(verse => (verse.DiscoveryEndpoints ?? Array.Empty<string>())
|
||||||
(verse.DiscoveryEndpoints ?? Array.Empty<string>()).Any(endpoint =>
|
.Select(endpoint => new { Verse = verse, Endpoint = endpoint }))
|
||||||
!string.IsNullOrWhiteSpace(endpoint)))
|
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Endpoint))
|
||||||
.GroupBy(verse => verse.VerseId, StringComparer.Ordinal)
|
|
||||||
.Select(group => group.First())
|
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (candidates.Length == 0)
|
if (candidates.Length == 0)
|
||||||
throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints.");
|
throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints.");
|
||||||
|
|
||||||
var failures = new List<string>();
|
var failures = new List<string>();
|
||||||
var observed = new List<string>();
|
var observed = new List<string>();
|
||||||
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)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds(
|
try
|
||||||
candidate.AuthorityRuntimeIds,
|
|
||||||
requiredAuthorityRuntimeId))
|
|
||||||
{
|
{
|
||||||
try
|
var advertisements = await FetchAdvertisementsAsync(candidate.Endpoint);
|
||||||
{
|
observed.AddRange(advertisements.Select(document =>
|
||||||
var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId);
|
$"{candidate.Endpoint}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]"));
|
||||||
using var advertisementsLease = await mesh
|
var advertisement = advertisements
|
||||||
.LeaseCollectionAsync<EveProviderAdvertisementDocument>(target, cancellationToken)
|
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
|
||||||
.ConfigureAwait(false);
|
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
|
||||||
var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false);
|
.Select(document => new
|
||||||
observed.AddRange(advertisements.Select(document =>
|
{
|
||||||
$"{target}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]"));
|
Document = document,
|
||||||
var advertisement = advertisements
|
Surface = document.Surfaces.FirstOrDefault(surface =>
|
||||||
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
|
(string.IsNullOrWhiteSpace(surfaceId) ||
|
||||||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
|
string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) &&
|
||||||
.Select(document => new
|
(string.IsNullOrWhiteSpace(surfaceKind) ||
|
||||||
{
|
string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal)))
|
||||||
Document = document,
|
})
|
||||||
Surface = document.Surfaces.FirstOrDefault(surface =>
|
.FirstOrDefault(match => match.Surface != null);
|
||||||
(string.IsNullOrWhiteSpace(surfaceId) ||
|
if (advertisement?.Surface == null)
|
||||||
string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) &&
|
continue;
|
||||||
(string.IsNullOrWhiteSpace(surfaceKind) ||
|
|
||||||
string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal)))
|
|
||||||
})
|
|
||||||
.FirstOrDefault(match => match.Surface != null);
|
|
||||||
if (advertisement?.Surface == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
return new EveUnityCultMeshProviderSelection(
|
return new EveUnityCultMeshProviderSelection(
|
||||||
rendezvousEndpoint,
|
candidate.Endpoint,
|
||||||
candidate.VerseId,
|
candidate.Verse.VerseId,
|
||||||
authorityRuntimeId,
|
advertisement.Document.ProviderId,
|
||||||
advertisement.Document.ProviderId,
|
advertisement.Surface.SurfaceId,
|
||||||
advertisement.Surface.SurfaceId,
|
advertisement.Surface.SurfaceKind);
|
||||||
advertisement.Surface.SurfaceKind);
|
}
|
||||||
}
|
catch (Exception error)
|
||||||
catch (Exception error)
|
{
|
||||||
{
|
failures.Add($"{candidate.Endpoint}: {error.Message}");
|
||||||
failures.Add($"{candidate.VerseId}/{authorityRuntimeId}: {error.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'";
|
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'";
|
||||||
var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}";
|
var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}";
|
||||||
var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}";
|
var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}";
|
||||||
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}");
|
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}");
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static IReadOnlyList<string> EligibleAuthorityRuntimeIds(
|
private static async Task<EveProviderAdvertisementDocument[]> FetchAdvertisementsAsync(string endpoint)
|
||||||
IEnumerable<string>? authorityRuntimeIds,
|
{
|
||||||
string requiredAuthorityRuntimeId) =>
|
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes);
|
||||||
(authorityRuntimeIds ?? Array.Empty<string>())
|
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry);
|
||||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
var snapshot = CultMesh.SnapshotEndpoint(
|
||||||
.Where(id => string.IsNullOrWhiteSpace(requiredAuthorityRuntimeId) ||
|
endpoint,
|
||||||
string.Equals(id, requiredAuthorityRuntimeId, StringComparison.Ordinal))
|
new CultMeshSnapshotEndpointOptions
|
||||||
.Distinct(StringComparer.Ordinal)
|
{
|
||||||
.OrderBy(id => id, StringComparer.Ordinal)
|
Context = CultMesh.Verse("eve.discovery", "eve-unity").Context,
|
||||||
.ToArray();
|
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<EveProviderAdvertisementDocument>()).ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,7 @@
|
||||||
"GameCult.Caching.MessagePack.dll",
|
"GameCult.Caching.MessagePack.dll",
|
||||||
"GameCult.Mesh.dll",
|
"GameCult.Mesh.dll",
|
||||||
"GameCult.Mesh.Quic.Native.dll",
|
"GameCult.Mesh.Quic.Native.dll",
|
||||||
"GameCult.Networking.dll",
|
"GameCult.Networking.dll"
|
||||||
"GameCult.Networking.WebSockets.dll"
|
|
||||||
],
|
],
|
||||||
"autoReferenced": true,
|
"autoReferenced": true,
|
||||||
"defineConstraints": [],
|
"defineConstraints": [],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
@ -30,378 +29,13 @@ namespace GameCult.Eve.UnityScene
|
||||||
|
|
||||||
public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; }
|
public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; }
|
||||||
|
|
||||||
public EveUnitySceneNavigationFailure? LastNavigationFailure { get; private set; }
|
|
||||||
|
|
||||||
public event Action<EveUnitySceneNavigationFailure>? NavigationFailed;
|
|
||||||
|
|
||||||
private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource;
|
|
||||||
private bool _navigationInProgress;
|
|
||||||
private StagedPresentation? _ownedPresentation;
|
|
||||||
|
|
||||||
public void ConfigureProvider(MonoBehaviour providerComponent)
|
public void ConfigureProvider(MonoBehaviour providerComponent)
|
||||||
{
|
{
|
||||||
UnbindNavigation();
|
|
||||||
provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent));
|
provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent));
|
||||||
providerSurfaceDocuments = provider;
|
providerSurfaceDocuments = provider;
|
||||||
commandSink = provider;
|
commandSink = provider;
|
||||||
assetManifestDocuments = provider;
|
assetManifestDocuments = provider;
|
||||||
receiptSource = 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<IEveUnitySceneProviderSurfaceDocumentSource>(
|
|
||||||
providerSurfaceDocuments,
|
|
||||||
resolvedProvider,
|
|
||||||
nameof(providerSurfaceDocuments));
|
|
||||||
var resolvedCommandSink = ResolveBehaviour<IEveUnitySceneCommandSink>(
|
|
||||||
commandSink,
|
|
||||||
resolvedProvider,
|
|
||||||
nameof(commandSink));
|
|
||||||
var resolvedAssetDocuments = ResolveOptionalBehaviour<IEveUnityPlayableWorldAssetManifestDocumentSource>(
|
|
||||||
assetManifestDocuments,
|
|
||||||
resolvedProvider);
|
|
||||||
var resolvedReceiptSource = ResolveOptionalBehaviour<IEveUnitySceneCommandReceiptSource>(
|
|
||||||
receiptSource,
|
|
||||||
resolvedProvider);
|
|
||||||
var resolvedFallbackAssetProvider = ResolveOptionalBehaviour<IEveUnityGameObjectAssetProvider>(
|
|
||||||
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<EveUnityPlayableWorldClientHost>();
|
|
||||||
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<EveUnityPlayableWorldInputDriver>();
|
|
||||||
var previousInputHost = input?.Host;
|
|
||||||
var previousInputCamera = input?.CameraTransform;
|
|
||||||
var rig = GetComponent<EveUnityPlayableWorldCameraRig>();
|
|
||||||
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()
|
public EveUnityPlayableWorldPresentation Mount()
|
||||||
|
|
@ -557,21 +191,4 @@ namespace GameCult.Eve.UnityScene
|
||||||
return null;
|
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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GameCult.Eve.Surface;
|
using GameCult.Eve.Surface;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
@ -235,43 +234,6 @@ namespace GameCult.Eve.UnityScene
|
||||||
event Action<EveUnitySceneCommandReceipt> ReceiptAvailable;
|
event Action<EveUnitySceneCommandReceipt> 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<string>? rendezvousEndpoints = null,
|
|
||||||
string authorityRuntimeId = "")
|
|
||||||
{
|
|
||||||
VerseId = verseId ?? "";
|
|
||||||
AuthorityRuntimeId = authorityRuntimeId ?? "";
|
|
||||||
ProviderId = providerId ?? "";
|
|
||||||
SurfaceId = surfaceId ?? "";
|
|
||||||
SurfaceKind = surfaceKind ?? "";
|
|
||||||
RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string VerseId { get; }
|
|
||||||
public string AuthorityRuntimeId { get; }
|
|
||||||
public string ProviderId { get; }
|
|
||||||
public string SurfaceId { get; }
|
|
||||||
public string SurfaceKind { get; }
|
|
||||||
public IReadOnlyList<string> RendezvousEndpoints { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class EveUnitySceneCommandReceipt
|
public sealed class EveUnitySceneCommandReceipt
|
||||||
{
|
{
|
||||||
public EveUnitySceneCommandReceipt(
|
public EveUnitySceneCommandReceipt(
|
||||||
|
|
@ -286,8 +248,7 @@ namespace GameCult.Eve.UnityScene
|
||||||
string surfaceId = "",
|
string surfaceId = "",
|
||||||
string message = "",
|
string message = "",
|
||||||
DateTimeOffset? issuedAtUtc = null,
|
DateTimeOffset? issuedAtUtc = null,
|
||||||
long sourceVersion = 0,
|
long sourceVersion = 0)
|
||||||
EveUnitySceneNavigationTarget? navigation = null)
|
|
||||||
{
|
{
|
||||||
ReceiptId = receiptId ?? "";
|
ReceiptId = receiptId ?? "";
|
||||||
Command = command ?? "";
|
Command = command ?? "";
|
||||||
|
|
@ -301,7 +262,6 @@ namespace GameCult.Eve.UnityScene
|
||||||
Message = message ?? "";
|
Message = message ?? "";
|
||||||
IssuedAtUtc = issuedAtUtc;
|
IssuedAtUtc = issuedAtUtc;
|
||||||
SourceVersion = sourceVersion;
|
SourceVersion = sourceVersion;
|
||||||
Navigation = navigation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Schema { get; }
|
public string Schema { get; }
|
||||||
|
|
@ -328,8 +288,6 @@ namespace GameCult.Eve.UnityScene
|
||||||
|
|
||||||
public long SourceVersion { get; }
|
public long SourceVersion { get; }
|
||||||
|
|
||||||
public EveUnitySceneNavigationTarget? Navigation { get; }
|
|
||||||
|
|
||||||
public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal);
|
public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal);
|
||||||
|
|
||||||
public bool ShouldRefreshProviderSurface =>
|
public bool ShouldRefreshProviderSurface =>
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,17 @@ namespace GameCult.Eve.UnityScene
|
||||||
{
|
{
|
||||||
private readonly IEveUnityPlayableWorldSceneSink _sceneSink;
|
private readonly IEveUnityPlayableWorldSceneSink _sceneSink;
|
||||||
private readonly IEveUnityPlayableWorldAssetResolver _assetResolver;
|
private readonly IEveUnityPlayableWorldAssetResolver _assetResolver;
|
||||||
|
private readonly bool _liveEntityBodyOwner;
|
||||||
private readonly HashSet<string> _knownEntityIds = new HashSet<string>(StringComparer.Ordinal);
|
private readonly HashSet<string> _knownEntityIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
public EveUnityPlayableWorldPresenter(
|
public EveUnityPlayableWorldPresenter(
|
||||||
IEveUnityPlayableWorldSceneSink sceneSink,
|
IEveUnityPlayableWorldSceneSink sceneSink,
|
||||||
IEveUnityPlayableWorldAssetResolver assetResolver)
|
IEveUnityPlayableWorldAssetResolver assetResolver,
|
||||||
|
bool liveEntityBodyOwner = false)
|
||||||
{
|
{
|
||||||
_sceneSink = sceneSink ?? throw new ArgumentNullException(nameof(sceneSink));
|
_sceneSink = sceneSink ?? throw new ArgumentNullException(nameof(sceneSink));
|
||||||
_assetResolver = assetResolver ?? throw new ArgumentNullException(nameof(assetResolver));
|
_assetResolver = assetResolver ?? throw new ArgumentNullException(nameof(assetResolver));
|
||||||
|
_liveEntityBodyOwner = liveEntityBodyOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
public EveUnityPlayableWorldPresentation Apply(EveUnitySceneProjection projection)
|
public EveUnityPlayableWorldPresentation Apply(EveUnitySceneProjection projection)
|
||||||
|
|
@ -46,9 +49,26 @@ namespace GameCult.Eve.UnityScene
|
||||||
{
|
{
|
||||||
if (world == null) throw new ArgumentNullException(nameof(world));
|
if (world == null) throw new ArgumentNullException(nameof(world));
|
||||||
|
|
||||||
var nextEntityIds = new HashSet<string>(StringComparer.Ordinal);
|
|
||||||
_sceneSink.ConfigureWorld(world);
|
_sceneSink.ConfigureWorld(world);
|
||||||
|
|
||||||
|
// A world that advertises a live entity body has one transform and
|
||||||
|
// membership owner: the SoA generation presenter. The durable Eve
|
||||||
|
// surface remains configuration/topology and must not periodically
|
||||||
|
// overwrite realtime body state.
|
||||||
|
if (_liveEntityBodyOwner &&
|
||||||
|
(!string.IsNullOrWhiteSpace(world.EntityViewPointerId) ||
|
||||||
|
!string.IsNullOrWhiteSpace(world.EntityBodyId)))
|
||||||
|
return new EveUnityPlayableWorldPresentation(
|
||||||
|
world.WorldRootId,
|
||||||
|
world.PlayerEntityId,
|
||||||
|
world.InputProfile,
|
||||||
|
world.CameraRig,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
_knownEntityIds.Count);
|
||||||
|
|
||||||
|
var nextEntityIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
var upserted = 0;
|
var upserted = 0;
|
||||||
foreach (var entity in world.Entities)
|
foreach (var entity in world.Entities)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,10 @@ namespace GameCult.Eve.UnityScene
|
||||||
_connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink);
|
_connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink);
|
||||||
_client = new EveUnityPlayableWorldLiveClient(
|
_client = new EveUnityPlayableWorldLiveClient(
|
||||||
_connection,
|
_connection,
|
||||||
new EveUnityPlayableWorldPresenter(sceneSink, assetResolver ?? new EveUnityAssetRefResolver()),
|
new EveUnityPlayableWorldPresenter(
|
||||||
|
sceneSink,
|
||||||
|
assetResolver ?? new EveUnityAssetRefResolver(),
|
||||||
|
liveEntityBodyOwner: _entityViews != null),
|
||||||
receiptSource);
|
receiptSource);
|
||||||
|
|
||||||
if (assetManifestDocuments != null)
|
if (assetManifestDocuments != null)
|
||||||
|
|
|
||||||
|
|
@ -172,11 +172,7 @@ namespace GameCult.Eve.UnityScene
|
||||||
worldRoot.GetProp("exposureAdaptation", "progressive"),
|
worldRoot.GetProp("exposureAdaptation", "progressive"),
|
||||||
ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f),
|
ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f),
|
||||||
ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f),
|
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) =>
|
private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) =>
|
||||||
|
|
@ -274,7 +270,7 @@ namespace GameCult.Eve.UnityScene
|
||||||
private static IReadOnlyList<string> ParseStringList(string value)
|
private static IReadOnlyList<string> ParseStringList(string value)
|
||||||
{
|
{
|
||||||
return (value ?? "")
|
return (value ?? "")
|
||||||
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
|
.Split(',')
|
||||||
.Select(part => part.Trim())
|
.Select(part => part.Trim())
|
||||||
.Where(part => !string.IsNullOrWhiteSpace(part))
|
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
|
@ -447,11 +443,7 @@ namespace GameCult.Eve.UnityScene
|
||||||
string exposureAdaptation = "progressive",
|
string exposureAdaptation = "progressive",
|
||||||
float exposureSpeedUp = 2f,
|
float exposureSpeedUp = 2f,
|
||||||
float exposureSpeedDown = 1f,
|
float exposureSpeedDown = 1f,
|
||||||
string colorGradingSpace = "",
|
string colorGradingSpace = "")
|
||||||
string assetProviderId = "",
|
|
||||||
string assetVerseId = "",
|
|
||||||
string assetAuthorityRuntimeId = "",
|
|
||||||
IReadOnlyList<string>? assetRendezvousEndpoints = null)
|
|
||||||
{
|
{
|
||||||
WorldRootId = worldRootId ?? "";
|
WorldRootId = worldRootId ?? "";
|
||||||
StatePointerId = statePointerId ?? "";
|
StatePointerId = statePointerId ?? "";
|
||||||
|
|
@ -515,10 +507,6 @@ namespace GameCult.Eve.UnityScene
|
||||||
ExposureSpeedUp = exposureSpeedUp;
|
ExposureSpeedUp = exposureSpeedUp;
|
||||||
ExposureSpeedDown = exposureSpeedDown;
|
ExposureSpeedDown = exposureSpeedDown;
|
||||||
ColorGradingSpace = colorGradingSpace ?? "";
|
ColorGradingSpace = colorGradingSpace ?? "";
|
||||||
AssetProviderId = assetProviderId ?? "";
|
|
||||||
AssetVerseId = assetVerseId ?? "";
|
|
||||||
AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? "";
|
|
||||||
AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty<string>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string WorldRootId { get; }
|
public string WorldRootId { get; }
|
||||||
|
|
@ -537,14 +525,6 @@ namespace GameCult.Eve.UnityScene
|
||||||
|
|
||||||
public string AssetManifest { get; }
|
public string AssetManifest { get; }
|
||||||
|
|
||||||
public string AssetProviderId { get; }
|
|
||||||
|
|
||||||
public string AssetVerseId { get; }
|
|
||||||
|
|
||||||
public string AssetAuthorityRuntimeId { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<string> AssetRendezvousEndpoints { get; }
|
|
||||||
|
|
||||||
public string InputProfile { get; }
|
public string InputProfile { get; }
|
||||||
|
|
||||||
public string CameraRig { get; }
|
public string CameraRig { get; }
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,16 @@ namespace GameCult.Eve.UnityScene
|
||||||
var document = host.GetComponent<UIDocument>();
|
var document = host.GetComponent<UIDocument>();
|
||||||
if (document != null) return document;
|
if (document != null) return document;
|
||||||
|
|
||||||
var panel = host.transform.parent?.GetComponentInParent<UIDocument>()?.panelSettings;
|
var theme = Resources.Load<ThemeStyleSheet>(ThemeResource);
|
||||||
if (panel == null)
|
if (theme == null)
|
||||||
{
|
throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing.");
|
||||||
var theme = Resources.Load<ThemeStyleSheet>(ThemeResource);
|
|
||||||
if (theme == null)
|
|
||||||
throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing.");
|
|
||||||
|
|
||||||
panel = ScriptableObject.CreateInstance<PanelSettings>();
|
var panel = ScriptableObject.CreateInstance<PanelSettings>();
|
||||||
panel.name = "Eve playable-world HUD panel";
|
panel.name = "Eve playable-world HUD panel";
|
||||||
panel.scaleMode = PanelScaleMode.ScaleWithScreenSize;
|
panel.scaleMode = PanelScaleMode.ScaleWithScreenSize;
|
||||||
panel.referenceResolution = new Vector2Int(1920, 1080);
|
panel.referenceResolution = new Vector2Int(1920, 1080);
|
||||||
panel.match = 0.5f;
|
panel.match = 0.5f;
|
||||||
panel.themeStyleSheet = theme;
|
panel.themeStyleSheet = theme;
|
||||||
}
|
|
||||||
|
|
||||||
document = host.AddComponent<UIDocument>();
|
document = host.AddComponent<UIDocument>();
|
||||||
document.panelSettings = panel;
|
document.panelSettings = panel;
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,11 @@ using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GameCult.Eve.Surface;
|
using GameCult.Eve.Surface;
|
||||||
using GameCult.Eve.UnityScene;
|
using GameCult.Eve.UnityScene;
|
||||||
using GameCult.Mesh;
|
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
|
|
@ -18,523 +14,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
{
|
{
|
||||||
public sealed class EveUnityCultMeshAssetLookupTests
|
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<InvalidOperationException>(() => 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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
var release = new TaskCompletionSource<bool>(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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
var sent = new List<string>();
|
|
||||||
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<string>();
|
|
||||||
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<string, EveSurfaceCommandRequest>)pendingField!.GetValue(transport)!;
|
|
||||||
pending.Add(request.CommandId, request);
|
|
||||||
var observed = new List<EveUnitySceneCommandReceipt>();
|
|
||||||
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<string, EveSurfaceCommandRequest>)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<EveUnitySceneCommandReceipt>();
|
|
||||||
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<InvalidOperationException>(() =>
|
|
||||||
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<string, EveSurfaceCommandRequest>)pendingField!.GetValue(transport)!;
|
|
||||||
pending.Add(request.CommandId, request);
|
|
||||||
var observed = new List<EveUnitySceneCommandReceipt>();
|
|
||||||
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<string, EveSurfaceCommandRequest>)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<EveUnitySceneCommandReceipt>();
|
|
||||||
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<string, string>
|
|
||||||
{
|
|
||||||
["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<string, string>(),
|
|
||||||
Array.Empty<EveSurfaceComponent>()),
|
|
||||||
Array.Empty<EveStyleToken>()),
|
|
||||||
Array.Empty<EveCommandTemplate>());
|
|
||||||
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<bool> 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]
|
[Test]
|
||||||
public void UnavailableTcpRendezvousIsReportedAsRetryablePreparationFailure()
|
public void UnavailableTcpRendezvousIsReportedAsRetryablePreparationFailure()
|
||||||
{
|
{
|
||||||
|
|
@ -646,16 +125,28 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem()
|
public void PublishingCatalogRegistersBundlesWithoutMaterializingThem()
|
||||||
{
|
{
|
||||||
var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
|
var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
|
||||||
"CurrentBundlePlatform",
|
"CurrentBundlePlatform",
|
||||||
BindingFlags.Static | BindingFlags.NonPublic);
|
BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
|
var publishMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
|
||||||
"ConfigureAssetCatalog",
|
"PublishAssetCatalog",
|
||||||
BindingFlags.Static | BindingFlags.NonPublic);
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
var bundlesField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
|
||||||
|
"_assetBundles",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
var selectionsField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
|
||||||
|
"_assetSelections",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
Assert.That(platformMethod, Is.Not.Null);
|
Assert.That(platformMethod, Is.Not.Null);
|
||||||
Assert.That(configureMethod, Is.Not.Null);
|
Assert.That(publishMethod, Is.Not.Null);
|
||||||
|
|
||||||
|
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||||
|
Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"),
|
||||||
|
"cultnet+tcp://127.0.0.1:1",
|
||||||
|
"provider",
|
||||||
|
"surface");
|
||||||
var variant = new EveAssetVariant(
|
var variant = new EveAssetVariant(
|
||||||
"unity-scene",
|
"unity-scene",
|
||||||
(string)platformMethod!.Invoke(null, Array.Empty<object>())!,
|
(string)platformMethod!.Invoke(null, Array.Empty<object>())!,
|
||||||
|
|
@ -670,229 +161,11 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
1,
|
1,
|
||||||
DateTimeOffset.UtcNow.ToString("O"),
|
DateTimeOffset.UtcNow.ToString("O"),
|
||||||
new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) });
|
new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) });
|
||||||
var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1);
|
|
||||||
|
|
||||||
configureMethod!.Invoke(null, new[] { candidate, catalog });
|
publishMethod!.Invoke(transport, new object[] { catalog });
|
||||||
|
|
||||||
Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero);
|
Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero);
|
||||||
Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1));
|
Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).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<TargetInvocationException>(() =>
|
|
||||||
configureMethod.Invoke(null, new[] { rejected, invalidCatalog }));
|
|
||||||
Assert.That(error!.InnerException, Is.TypeOf<InvalidOperationException>());
|
|
||||||
((IDisposable)rejected).Dispose();
|
|
||||||
Assert.That(generationField.GetValue(transport), Is.SameAs(committed));
|
|
||||||
Assert.That(GenerationProperty<string>(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<object>()), 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<EveAssetCatalogEntry>());
|
|
||||||
|
|
||||||
queue.Invoke(transport, new object[] { "source-a", catalog42 });
|
|
||||||
|
|
||||||
Assert.That(GenerationProperty<long>(generationField.GetValue(transport)!, "CatalogVersion"), Is.EqualTo(41));
|
|
||||||
var committedSource = GenerationProperty<object>(generationField.GetValue(transport)!, "Source");
|
|
||||||
Assert.That(GenerationProperty<long>(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<EveAssetCatalogEntry>());
|
|
||||||
|
|
||||||
var error = Assert.Throws<TargetInvocationException>(() => require.Invoke(
|
|
||||||
null,
|
|
||||||
new object[] { catalog42, 41L, "eve:assets:aetheria.daemon:version:41" }));
|
|
||||||
|
|
||||||
Assert.That(error!.InnerException, Is.TypeOf<InvalidDataException>());
|
|
||||||
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<string>(),
|
|
||||||
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<T>(object generation, string property) =>
|
|
||||||
(T)generation.GetType().GetProperty(property)!.GetValue(generation)!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@ using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Collections;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GameCult.Caching;
|
using GameCult.Caching;
|
||||||
using GameCult.Eve.PluginFields;
|
using GameCult.Eve.PluginFields;
|
||||||
using GameCult.Eve.Surface;
|
using GameCult.Eve.Surface;
|
||||||
|
|
@ -15,7 +12,6 @@ using UnityEngine;
|
||||||
using UnityEngine.Rendering;
|
using UnityEngine.Rendering;
|
||||||
using UnityEngine.Rendering.Universal;
|
using UnityEngine.Rendering.Universal;
|
||||||
using UnityEngine.UIElements;
|
using UnityEngine.UIElements;
|
||||||
using UnityEngine.TestTools;
|
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
|
|
@ -29,11 +25,9 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
Assert.DoesNotThrow(() =>
|
Assert.DoesNotThrow(() =>
|
||||||
{
|
{
|
||||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||||
"test-cache",
|
"test-replica.cc",
|
||||||
"cultnet://127.0.0.1:3075",
|
"cultnet://127.0.0.1:3075",
|
||||||
"aetheria",
|
"aetheria",
|
||||||
"aetheria",
|
|
||||||
"aetheria",
|
|
||||||
"aetheria.pilot");
|
"aetheria.pilot");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -65,11 +59,9 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
public void LiveTransportReusesStableLayoutForNewBodyPublications()
|
public void LiveTransportReusesStableLayoutForNewBodyPublications()
|
||||||
{
|
{
|
||||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||||
"test-cache",
|
"test-replica.cc",
|
||||||
"cultnet://127.0.0.1:3075",
|
"cultnet://127.0.0.1:3075",
|
||||||
"aetheria",
|
"aetheria",
|
||||||
"aetheria",
|
|
||||||
"aetheria",
|
|
||||||
"aetheria.pilot");
|
"aetheria.pilot");
|
||||||
var view = EntityLeaseDocument();
|
var view = EntityLeaseDocument();
|
||||||
var publication = BodyPublication(view, view.Sequence + 1);
|
var publication = BodyPublication(view, view.Sequence + 1);
|
||||||
|
|
@ -101,11 +93,9 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
public void LiveTransportPairsBodyPublicationThatArrivesBeforeItsLayout()
|
public void LiveTransportPairsBodyPublicationThatArrivesBeforeItsLayout()
|
||||||
{
|
{
|
||||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||||
"test-cache",
|
"test-replica.cc",
|
||||||
"cultnet://127.0.0.1:3075",
|
"cultnet://127.0.0.1:3075",
|
||||||
"aetheria",
|
"aetheria",
|
||||||
"aetheria",
|
|
||||||
"aetheria",
|
|
||||||
"aetheria.pilot");
|
"aetheria.pilot");
|
||||||
var view = EntityLeaseDocument();
|
var view = EntityLeaseDocument();
|
||||||
var publication = BodyPublication(view, view.Sequence);
|
var publication = BodyPublication(view, view.Sequence);
|
||||||
|
|
@ -131,11 +121,9 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
public void LiveTransportKeepsOnlyLatestPendingRealtimeGenerationPerBody()
|
public void LiveTransportKeepsOnlyLatestPendingRealtimeGenerationPerBody()
|
||||||
{
|
{
|
||||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||||
"test-cache",
|
"test-replica.cc",
|
||||||
"cultnet://127.0.0.1:3075",
|
"cultnet://127.0.0.1:3075",
|
||||||
"provider",
|
"provider",
|
||||||
"provider",
|
|
||||||
"provider",
|
|
||||||
"provider.pilot");
|
"provider.pilot");
|
||||||
var enqueue = typeof(EveUnityCultMeshLiveProviderTransport)
|
var enqueue = typeof(EveUnityCultMeshLiveProviderTransport)
|
||||||
.GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
.GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||||
|
|
@ -162,11 +150,9 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments()
|
public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments()
|
||||||
{
|
{
|
||||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||||
"test-cache",
|
"test-replica.cc",
|
||||||
"cultnet://127.0.0.1:3075",
|
"cultnet://127.0.0.1:3075",
|
||||||
"provider",
|
"provider",
|
||||||
"provider",
|
|
||||||
"provider",
|
|
||||||
"provider.pilot");
|
"provider.pilot");
|
||||||
var view = EntityLeaseDocument();
|
var view = EntityLeaseDocument();
|
||||||
view.Sequence = 0;
|
view.Sequence = 0;
|
||||||
|
|
@ -245,20 +231,14 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void LiveTransportHasNoSnapshotWireRegistryForBodyBytes()
|
public void LiveTransportDoesNotRegisterBodyBytesAsSnapshotDocuments()
|
||||||
{
|
{
|
||||||
var transport = typeof(EveUnityCultMeshLiveProviderTransport);
|
var wireTypes = (Type[])typeof(EveUnityCultMeshLiveProviderTransport)
|
||||||
var wireRegistry = transport.GetField(
|
.GetField("WireDocumentTypes", BindingFlags.NonPublic | BindingFlags.Static)!
|
||||||
"WireDocumentTypes",
|
.GetValue(null)!;
|
||||||
BindingFlags.NonPublic | BindingFlags.Static);
|
|
||||||
var fieldTypes = transport
|
|
||||||
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
|
|
||||||
.Select(field => field.FieldType)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
Assert.That(wireRegistry, Is.Null);
|
Assert.That(wireTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False);
|
||||||
Assert.That(fieldTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False);
|
Assert.That(wireTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False);
|
||||||
Assert.That(fieldTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -582,10 +562,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
Assert.That(projection.PlayableWorld.EntityBodyId, Is.EqualTo("eve:entity-soa:aetheria.daemon:pilot"));
|
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.ZoneRenderPointerId, Is.EqualTo("cultmesh://aetheria/world/zone-render"));
|
||||||
Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest"));
|
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.InputProfile, Is.EqualTo("arpg-third-person"));
|
||||||
Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1"));
|
Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1"));
|
||||||
Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty);
|
Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty);
|
||||||
|
|
@ -925,19 +901,11 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
"aetheria.daemon.move_intent",
|
"aetheria.daemon.move_intent",
|
||||||
"accepted",
|
"accepted",
|
||||||
"Aetheria",
|
"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, Is.Not.Null);
|
||||||
Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True);
|
Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True);
|
||||||
Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, 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]
|
[Test]
|
||||||
|
|
@ -1155,204 +1123,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[UnityTest]
|
|
||||||
public IEnumerator PlayableWorldClientBootstrapFollowsProviderOwnedReceiptNavigation()
|
|
||||||
{
|
|
||||||
var hostObject = new GameObject("generic-eve-navigation-client");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var provider = hostObject.AddComponent<FakePlayableWorldProviderComponent>();
|
|
||||||
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<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
|
|
||||||
"aetheria"));
|
|
||||||
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
|
|
||||||
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<Transform>()
|
|
||||||
.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<FakePlayableWorldProviderComponent>();
|
|
||||||
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<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
|
|
||||||
"aetheria"));
|
|
||||||
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
|
|
||||||
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<FakePlayableWorldProviderComponent>();
|
|
||||||
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<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
|
|
||||||
"aetheria"));
|
|
||||||
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
|
|
||||||
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]
|
[Test]
|
||||||
public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector()
|
public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector()
|
||||||
{
|
{
|
||||||
|
|
@ -2086,7 +1856,7 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
var presenter = new EveUnityPlayableWorldPresenter(sink, new EveUnityAssetRefResolver());
|
var presenter = new EveUnityPlayableWorldPresenter(sink, new EveUnityAssetRefResolver());
|
||||||
|
|
||||||
var firstProjection = lowerer.Lower(PlayableArpgDocument(), Advertisement("aetheria.daemon.game"));
|
var firstProjection = lowerer.Lower(PlayableArpgDocument(), Advertisement("aetheria.daemon.game"));
|
||||||
var firstPresentation = presenter.Apply(firstProjection);
|
var firstPresentation = presenter.Apply(SnapshotWorld(firstProjection.PlayableWorld!));
|
||||||
|
|
||||||
Assert.That(firstPresentation.WorldRootId, Is.EqualTo("aetheria.daemon.game.playable"));
|
Assert.That(firstPresentation.WorldRootId, Is.EqualTo("aetheria.daemon.game.playable"));
|
||||||
Assert.That(firstPresentation.PlayerEntityId, Is.EqualTo("player-vanguard"));
|
Assert.That(firstPresentation.PlayerEntityId, Is.EqualTo("player-vanguard"));
|
||||||
|
|
@ -2105,7 +1875,7 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
var secondProjection = lowerer.Lower(
|
var secondProjection = lowerer.Lower(
|
||||||
PlayableArpgDocument(includeRaider: false, playerPosition: "5,0,2"),
|
PlayableArpgDocument(includeRaider: false, playerPosition: "5,0,2"),
|
||||||
Advertisement("aetheria.daemon.game"));
|
Advertisement("aetheria.daemon.game"));
|
||||||
var secondPresentation = presenter.Apply(secondProjection);
|
var secondPresentation = presenter.Apply(SnapshotWorld(secondProjection.PlayableWorld!));
|
||||||
|
|
||||||
Assert.That(secondPresentation.UpsertedEntities, Is.EqualTo(2));
|
Assert.That(secondPresentation.UpsertedEntities, Is.EqualTo(2));
|
||||||
Assert.That(secondPresentation.RemovedEntities, Is.EqualTo(1));
|
Assert.That(secondPresentation.RemovedEntities, Is.EqualTo(1));
|
||||||
|
|
@ -2118,6 +1888,27 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
Assert.That(sink.Upserts[3].entity.PositionZ, Is.EqualTo(2f));
|
Assert.That(sink.Upserts[3].entity.PositionZ, Is.EqualTo(2f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void PlayableWorldSurfaceCannotOverwriteLiveBodyEntities()
|
||||||
|
{
|
||||||
|
var projection = new EveUnitySceneSurfaceLowerer()
|
||||||
|
.Lower(PlayableArpgDocument(), Advertisement("aetheria.daemon.game"));
|
||||||
|
var sink = new FakePlayableWorldSceneSink();
|
||||||
|
var presenter = new EveUnityPlayableWorldPresenter(
|
||||||
|
sink,
|
||||||
|
new EveUnityAssetRefResolver(),
|
||||||
|
liveEntityBodyOwner: true);
|
||||||
|
|
||||||
|
var presentation = presenter.Apply(projection);
|
||||||
|
|
||||||
|
Assert.That(projection.PlayableWorld!.EntityBodyId, Is.Not.Empty);
|
||||||
|
Assert.That(sink.ConfiguredWorlds.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(sink.Upserts, Is.Empty);
|
||||||
|
Assert.That(sink.RemovedEntityIds, Is.Empty);
|
||||||
|
Assert.That(presentation.UpsertedEntities, Is.Zero);
|
||||||
|
Assert.That(presentation.RemovedEntities, Is.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void LivePlayableWorldClientPresentsProviderSnapshotsAndKeepsCommandsProviderOwned()
|
public void LivePlayableWorldClientPresentsProviderSnapshotsAndKeepsCommandsProviderOwned()
|
||||||
{
|
{
|
||||||
|
|
@ -2734,10 +2525,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render",
|
["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render",
|
||||||
["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1",
|
["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1",
|
||||||
["assetManifest"] = "cultmesh://aetheria/assets/manifest",
|
["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",
|
["inputProfile"] = "arpg-third-person",
|
||||||
["cameraRig"] = cameraRig,
|
["cameraRig"] = cameraRig,
|
||||||
["cameraLookAt"] = cameraLookAt,
|
["cameraLookAt"] = cameraLookAt,
|
||||||
|
|
@ -3184,6 +2971,21 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static EveUnityPlayableWorldProjection SnapshotWorld(EveUnityPlayableWorldProjection source) =>
|
||||||
|
new EveUnityPlayableWorldProjection(
|
||||||
|
source.WorldRootId,
|
||||||
|
source.StatePointerId,
|
||||||
|
source.AssetManifest,
|
||||||
|
source.InputProfile,
|
||||||
|
source.CameraRig,
|
||||||
|
source.ViewId,
|
||||||
|
source.PlayerEntityId,
|
||||||
|
source.MovementCommand,
|
||||||
|
source.FocusCommand,
|
||||||
|
source.TargetCommand,
|
||||||
|
source.ActionCommand,
|
||||||
|
source.Entities);
|
||||||
|
|
||||||
private static EveUnityPlayableWorldEntity PlayableEntityModel(
|
private static EveUnityPlayableWorldEntity PlayableEntityModel(
|
||||||
string entityId,
|
string entityId,
|
||||||
string assetRef,
|
string assetRef,
|
||||||
|
|
@ -3442,38 +3244,12 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
IEveUnitySceneCommandSink,
|
IEveUnitySceneCommandSink,
|
||||||
IEveUnitySceneCommandReceiptSource,
|
IEveUnitySceneCommandReceiptSource,
|
||||||
IEveUnityProviderRefreshSource,
|
IEveUnityProviderRefreshSource,
|
||||||
IEveUnityInputCapabilitySource,
|
IEveUnityInputCapabilitySource
|
||||||
IEveUnitySceneProviderSurfaceDocumentConnection,
|
|
||||||
IEveUnityNavigableProvider
|
|
||||||
{
|
{
|
||||||
public List<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
|
public List<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
|
||||||
|
|
||||||
public int RefreshCount { get; private set; }
|
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<bool>? 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 SinkKind => "fake-provider-command-sink";
|
||||||
|
|
||||||
public string ManifestRef => CurrentDocument.ManifestRef;
|
public string ManifestRef => CurrentDocument.ManifestRef;
|
||||||
|
|
@ -3495,9 +3271,7 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
new EveInputCapabilityDocument();
|
new EveInputCapabilityDocument();
|
||||||
|
|
||||||
EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument =>
|
EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument =>
|
||||||
_failSurfaceRead
|
CurrentSurfaceDocument;
|
||||||
? throw new InvalidOperationException("Deliberate candidate surface read failure.")
|
|
||||||
: CurrentSurfaceDocument;
|
|
||||||
|
|
||||||
public event Action<EveUnitySceneProviderSurfaceDocument>? DocumentAvailable;
|
public event Action<EveUnitySceneProviderSurfaceDocument>? DocumentAvailable;
|
||||||
|
|
||||||
|
|
@ -3537,51 +3311,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
||||||
{
|
{
|
||||||
ReceiptAvailable?.Invoke(receipt);
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,14 @@
|
||||||
"requiredPackageDependencies": [
|
"requiredPackageDependencies": [
|
||||||
{
|
{
|
||||||
"packageName": "org.gamecult.eve.surface",
|
"packageName": "org.gamecult.eve.surface",
|
||||||
"version": "0.3.0",
|
"version": "0.2.4",
|
||||||
"packageManager": "upm",
|
"packageManager": "upm",
|
||||||
"ownerRepo": "Eve",
|
"ownerRepo": "EveUnity",
|
||||||
"purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents."
|
"purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageName": "org.gamecult.eve.unity-uitoolkit",
|
"packageName": "org.gamecult.eve.unity-uitoolkit",
|
||||||
"version": "0.1.3",
|
"version": "0.1.2",
|
||||||
"packageManager": "upm",
|
"packageManager": "upm",
|
||||||
"ownerRepo": "EveUnity",
|
"ownerRepo": "EveUnity",
|
||||||
"purpose": "Generic UI Toolkit lowering for provider-owned Eve panes, metrics, progress, text, and controls."
|
"purpose": "Generic UI Toolkit lowering for provider-owned Eve panes, metrics, progress, text, and controls."
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "org.gamecult.eve.unity-scene",
|
"name": "org.gamecult.eve.unity-scene",
|
||||||
"displayName": "Eve Unity Scene Lowering",
|
"displayName": "Eve Unity Scene Lowering",
|
||||||
"version": "0.3.134",
|
"version": "0.3.104",
|
||||||
"unity": "6000.0",
|
"unity": "6000.0",
|
||||||
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
|
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.cultlib": "1.0.56",
|
"org.gamecult.cultlib": "1.0.43",
|
||||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||||
"org.gamecult.eve.surface": "0.3.6",
|
"org.gamecult.eve.surface": "0.2.4",
|
||||||
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
|
"org.gamecult.eve.unity-uitoolkit": "0.1.2",
|
||||||
"com.unity.mathematics": "1.3.2",
|
"com.unity.mathematics": "1.3.2",
|
||||||
"com.unity.render-pipelines.universal": "17.4.0",
|
"com.unity.render-pipelines.universal": "17.4.0",
|
||||||
"com.unity.modules.particlesystem": "1.0.0"
|
"com.unity.modules.particlesystem": "1.0.0"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
private Vector2 _inventoryDragStart;
|
private Vector2 _inventoryDragStart;
|
||||||
private bool _inventoryPointerMoved;
|
private bool _inventoryPointerMoved;
|
||||||
private bool _suppressInventoryClick;
|
private bool _suppressInventoryClick;
|
||||||
private readonly List<VisualElement> _inventoryPreviewCells = new();
|
|
||||||
|
|
||||||
public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null)
|
public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null)
|
||||||
{
|
{
|
||||||
|
|
@ -46,16 +45,10 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}");
|
element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}");
|
||||||
element.userData = component;
|
element.userData = component;
|
||||||
ApplyGeneratedLayout(element, component);
|
ApplyGeneratedLayout(element, component);
|
||||||
element.SetEnabled(IsEnabled(component));
|
|
||||||
|
|
||||||
if (IsHidden(component) || IsExternalProjectionRoot(component.Kind))
|
if (IsHidden(component) || IsExternalProjectionRoot(component.Kind))
|
||||||
return element;
|
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)
|
foreach (var child in component.Children)
|
||||||
{
|
{
|
||||||
var loweredChild = LowerComponent(child, document, commandSink);
|
var loweredChild = LowerComponent(child, document, commandSink);
|
||||||
|
|
@ -121,8 +114,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
return InventoryItem(component, document, commandSink);
|
return InventoryItem(component, document, commandSink);
|
||||||
case EveInventoryInteraction.DragSessionKind:
|
case EveInventoryInteraction.DragSessionKind:
|
||||||
return InventoryDragSession(component);
|
return InventoryDragSession(component);
|
||||||
case "control.select":
|
|
||||||
return Select(component, document, commandSink);
|
|
||||||
case "partition":
|
case "partition":
|
||||||
{
|
{
|
||||||
var element = new VisualElement();
|
var element = new VisualElement();
|
||||||
|
|
@ -132,8 +123,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
element.style.flexWrap = Wrap.Wrap;
|
element.style.flexWrap = Wrap.Wrap;
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
case "scroll":
|
|
||||||
return new ScrollView(ScrollViewMode.Vertical);
|
|
||||||
case "pane":
|
case "pane":
|
||||||
case "modal":
|
case "modal":
|
||||||
case "card":
|
case "card":
|
||||||
|
|
@ -277,7 +266,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
return;
|
return;
|
||||||
if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink))
|
if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink))
|
||||||
_inventoryDragSource = null;
|
_inventoryDragSource = null;
|
||||||
ClearInventoryPreview();
|
|
||||||
});
|
});
|
||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +284,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
_inventoryDragSource = component;
|
_inventoryDragSource = component;
|
||||||
_inventoryDragStart = evt.position;
|
_inventoryDragStart = evt.position;
|
||||||
_inventoryPointerMoved = false;
|
_inventoryPointerMoved = false;
|
||||||
ClearInventoryPreview();
|
|
||||||
item.CapturePointer(evt.pointerId);
|
item.CapturePointer(evt.pointerId);
|
||||||
evt.StopPropagation();
|
evt.StopPropagation();
|
||||||
});
|
});
|
||||||
|
|
@ -306,15 +293,7 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
return;
|
return;
|
||||||
var pointerPosition = new Vector2(evt.position.x, evt.position.y);
|
var pointerPosition = new Vector2(evt.position.x, evt.position.y);
|
||||||
if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f)
|
if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f)
|
||||||
{
|
|
||||||
_inventoryPointerMoved = true;
|
_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<PointerUpEvent>(evt =>
|
item.RegisterCallback<PointerUpEvent>(evt =>
|
||||||
{
|
{
|
||||||
|
|
@ -329,7 +308,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink);
|
TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink);
|
||||||
_inventoryDragSource = null;
|
_inventoryDragSource = null;
|
||||||
_suppressInventoryClick = true;
|
_suppressInventoryClick = true;
|
||||||
ClearInventoryPreview();
|
|
||||||
}
|
}
|
||||||
evt.StopPropagation();
|
evt.StopPropagation();
|
||||||
});
|
});
|
||||||
|
|
@ -421,50 +399,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
return true;
|
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) =>
|
private static int ParseInt(string value, int fallback) =>
|
||||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
|
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
|
||||||
|
|
||||||
|
|
@ -514,62 +448,10 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
foreach (var template in document.Commands)
|
foreach (var template in document.Commands)
|
||||||
{
|
{
|
||||||
if (string.Equals(template.Command, command, StringComparison.Ordinal))
|
if (string.Equals(template.Command, command, StringComparison.Ordinal))
|
||||||
return GameCult.Mesh.CultMesh.OperationInvocation(
|
return GameCult.Mesh.CultMesh.OperationInvocation(template.Operation);
|
||||||
template.Operation,
|
|
||||||
idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return GameCult.Mesh.CultMesh.OperationInvocation(
|
return GameCult.Mesh.CultMesh.OperationInvocation(command);
|
||||||
command,
|
|
||||||
idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static VisualElement Select(
|
|
||||||
EveSurfaceComponent component,
|
|
||||||
EveSurfaceDocument document,
|
|
||||||
Action<EveSurfaceCommandRequest>? 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<string, string>(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)
|
private static Label TitleLabel(string text)
|
||||||
|
|
@ -667,12 +549,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
element.style.alignItems = align;
|
element.style.alignItems = align;
|
||||||
if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify))
|
if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify))
|
||||||
element.style.justifyContent = 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))
|
if (TryGet(layout, "width", out var width))
|
||||||
element.style.width = ParseLength(width);
|
element.style.width = ParseLength(width);
|
||||||
if (TryGet(layout, "minWidth", out var minWidth))
|
if (TryGet(layout, "minWidth", out var minWidth))
|
||||||
|
|
@ -749,14 +625,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
||||||
return false;
|
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)
|
private static StyleLength ParseLength(string value)
|
||||||
{
|
{
|
||||||
value = FirstToken(value);
|
value = FirstToken(value);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using GameCult.Eve.Surface;
|
using GameCult.Eve.Surface;
|
||||||
using GameCult.Mesh;
|
using GameCult.Mesh;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEditor;
|
|
||||||
using UnityEngine.UIElements;
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
@ -13,108 +11,6 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
|
||||||
{
|
{
|
||||||
public sealed class EveUiToolkitSurfaceLowererTests
|
public sealed class EveUiToolkitSurfaceLowererTests
|
||||||
{
|
{
|
||||||
[Test]
|
|
||||||
public void SelectLowersProviderOptionsAndEmitsSelectedTypedValue()
|
|
||||||
{
|
|
||||||
EveSurfaceCommandRequest? emitted = null;
|
|
||||||
var select = Component(
|
|
||||||
"verse",
|
|
||||||
"control.select",
|
|
||||||
new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["label"] = "VERSE",
|
|
||||||
["value"] = "local",
|
|
||||||
["command"] = "eve.client.verse.select"
|
|
||||||
},
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
Component("local", "control.option", new Dictionary<string, string> { ["label"] = "Local", ["value"] = "local" }),
|
|
||||||
Component("hosted", "control.option", new Dictionary<string, string> { ["label"] = "Hosted", ["value"] = "hosted" })
|
|
||||||
});
|
|
||||||
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request);
|
|
||||||
var field = root.Q<DropdownField>();
|
|
||||||
var window = EditorWindow.CreateInstance<EditorWindow>();
|
|
||||||
|
|
||||||
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<string, string>
|
|
||||||
{
|
|
||||||
["label"] = "VERSE",
|
|
||||||
["value"] = "verse:a",
|
|
||||||
["command"] = "eve.client.verse.select"
|
|
||||||
},
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
Component("a", "control.option", new Dictionary<string, string> { ["label"] = "Aetheria", ["value"] = "verse:a" }),
|
|
||||||
Component("b", "control.option", new Dictionary<string, string> { ["label"] = "Aetheria", ["value"] = "verse:b" })
|
|
||||||
});
|
|
||||||
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request);
|
|
||||||
var field = root.Q<DropdownField>();
|
|
||||||
var window = EditorWindow.CreateInstance<EditorWindow>();
|
|
||||||
|
|
||||||
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<string, string> { ["label"] = "LAUNCH", ["disabled"] = "true" },
|
|
||||||
Array.Empty<EveSurfaceComponent>(),
|
|
||||||
Array.Empty<CultMeshStateBindingDescriptor>(),
|
|
||||||
Array.Empty<EveEmbeddedDocumentSlot>(),
|
|
||||||
new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["minWidth"] = "280",
|
|
||||||
["maxWidth"] = "360",
|
|
||||||
["flexGrow"] = "1"
|
|
||||||
});
|
|
||||||
|
|
||||||
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(button));
|
|
||||||
var lowered = root.Q<Button>();
|
|
||||||
|
|
||||||
Assert.That(lowered.enabledSelf, Is.False);
|
|
||||||
Assert.That(lowered.style.minWidth.value.value, Is.EqualTo(280f));
|
|
||||||
Assert.That(lowered.style.maxWidth.value.value, Is.EqualTo(360f));
|
|
||||||
Assert.That(lowered.style.flexGrow.value, Is.EqualTo(1f));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void DefaultOptionsExposeSaiNornAndTeXProjectionAdapters()
|
public void DefaultOptionsExposeSaiNornAndTeXProjectionAdapters()
|
||||||
{
|
{
|
||||||
|
|
@ -308,112 +204,18 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
|
||||||
["targetKind"] = "equipment",
|
["targetKind"] = "equipment",
|
||||||
["targetEntityKey"] = "zone.0.entity.1",
|
["targetEntityKey"] = "zone.0.entity.1",
|
||||||
["targetIndex"] = "-1",
|
["targetIndex"] = "-1",
|
||||||
["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem",
|
["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem"
|
||||||
["payload.shipId"] = "hangar.ship.1",
|
|
||||||
["payload.expectedHangarRevision"] = "42"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.That(EveInventoryInteraction.TryCreateDropRequest(
|
Assert.That(EveInventoryInteraction.TryCreateDropRequest(
|
||||||
Document(target), source, target, 7, 9, "unity-test", out var request), Is.True);
|
Document(target), source, target, 7, 9, "unity-test", out var request), Is.True);
|
||||||
Assert.That(request, Is.Not.Null);
|
Assert.That(request, Is.Not.Null);
|
||||||
Assert.That(request!.Command, Is.EqualTo("aetheria.daemon.commands.EquipItem"));
|
Assert.That(request!.Command, Is.EqualTo("aetheria.daemon.commands.EquipItem"));
|
||||||
Assert.That(request.CommandId, Is.Not.Empty);
|
|
||||||
Assert.That(request.Payload.GetString("originEntityKey"), Is.EqualTo("zone.0.entity.1"));
|
Assert.That(request.Payload.GetString("originEntityKey"), Is.EqualTo("zone.0.entity.1"));
|
||||||
Assert.That(request.Payload.GetString("originCargoIndex"), Is.EqualTo("2"));
|
Assert.That(request.Payload.GetString("originCargoIndex"), Is.EqualTo("2"));
|
||||||
Assert.That(request.Payload.GetString("destinationX"), Is.EqualTo("7"));
|
Assert.That(request.Payload.GetString("destinationX"), Is.EqualTo("7"));
|
||||||
Assert.That(request.Payload.GetString("destinationY"), Is.EqualTo("9"));
|
Assert.That(request.Payload.GetString("destinationY"), Is.EqualTo("9"));
|
||||||
Assert.That(request.Payload.GetString("hasDestinationPosition"), Is.EqualTo("true"));
|
Assert.That(request.Payload.GetString("hasDestinationPosition"), Is.EqualTo("true"));
|
||||||
Assert.That(request.Payload.GetString("shipId"), Is.EqualTo("hangar.ship.1"));
|
|
||||||
Assert.That(request.Payload.GetString("expectedHangarRevision"), Is.EqualTo("42"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void InventoryPlacementPreviewUsesIrregularShapeAndCurrentOccupancy()
|
|
||||||
{
|
|
||||||
var moving = Component("moving", EveInventoryInteraction.ItemKind, new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["shapeCells"] = "0,0;1,0;0,1"
|
|
||||||
});
|
|
||||||
var installed = Component("installed", EveInventoryInteraction.ItemKind, new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["x"] = "2",
|
|
||||||
["y"] = "1",
|
|
||||||
["shapeCells"] = "0,0"
|
|
||||||
});
|
|
||||||
var grid = new EveSurfaceComponent(
|
|
||||||
"grid",
|
|
||||||
EveInventoryInteraction.GridKind,
|
|
||||||
new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["columns"] = "4",
|
|
||||||
["rows"] = "3",
|
|
||||||
["validCells"] = "0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1;0,2;1,2;2,2;3,2"
|
|
||||||
},
|
|
||||||
new[] { installed });
|
|
||||||
|
|
||||||
Assert.That(EveInventoryInteraction.TryCreatePlacementPreview(
|
|
||||||
moving, grid, 0, 0, out var valid), Is.True);
|
|
||||||
Assert.That(valid!.IsValid, Is.True);
|
|
||||||
Assert.That(valid.Cells.Select(cell => (cell.X, cell.Y)),
|
|
||||||
Is.EquivalentTo(new[] { (0, 0), (1, 0), (0, 1) }));
|
|
||||||
|
|
||||||
Assert.That(EveInventoryInteraction.TryCreatePlacementPreview(
|
|
||||||
moving, grid, 1, 1, out var occupied), Is.True);
|
|
||||||
Assert.That(occupied!.IsValid, Is.False);
|
|
||||||
Assert.That(occupied.Reason, Is.EqualTo("occupied"));
|
|
||||||
|
|
||||||
Assert.That(EveInventoryInteraction.TryCreatePlacementPreview(
|
|
||||||
moving, grid, 3, 2, out var outside), Is.True);
|
|
||||||
Assert.That(outside!.IsValid, Is.False);
|
|
||||||
Assert.That(outside.Reason, Is.EqualTo("outside-grid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void InvalidLocalPreviewStillEmitsProviderOwnedDrop()
|
|
||||||
{
|
|
||||||
EveSurfaceCommandRequest? emitted = null;
|
|
||||||
var source = Component("moving", EveInventoryInteraction.ItemKind, new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["sourceKind"] = "equipment",
|
|
||||||
["sourceIndex"] = "0",
|
|
||||||
["itemKey"] = "reactor",
|
|
||||||
["shapeCells"] = "0,0"
|
|
||||||
});
|
|
||||||
var occupied = Component("stored", EveInventoryInteraction.ItemKind, new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["x"] = "0",
|
|
||||||
["y"] = "0",
|
|
||||||
["shapeCells"] = "0,0"
|
|
||||||
});
|
|
||||||
var target = new EveSurfaceComponent(
|
|
||||||
"hangar.inventory",
|
|
||||||
EveInventoryInteraction.GridKind,
|
|
||||||
new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["columns"] = "8",
|
|
||||||
["rows"] = "2",
|
|
||||||
["targetKind"] = "hangar",
|
|
||||||
["dropCommand.equipment"] = "aetheria.hangar.remove_item"
|
|
||||||
},
|
|
||||||
new[] { occupied });
|
|
||||||
var method = typeof(EveUiToolkitSurfaceLowerer).GetMethod(
|
|
||||||
"TryEmitInventoryDrop",
|
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
||||||
|
|
||||||
Assert.That(method, Is.Not.Null);
|
|
||||||
var emittedDrop = (bool)method!.Invoke(null, new object[]
|
|
||||||
{
|
|
||||||
Document(target),
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
new VisualElement(),
|
|
||||||
UnityEngine.Vector2.zero,
|
|
||||||
new Action<EveSurfaceCommandRequest>(request => emitted = request)
|
|
||||||
})!;
|
|
||||||
|
|
||||||
Assert.That(emittedDrop, Is.True);
|
|
||||||
Assert.That(emitted, Is.Not.Null);
|
|
||||||
Assert.That(emitted!.Command, Is.EqualTo("aetheria.hangar.remove_item"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,10 @@
|
||||||
"requiredPackageDependencies": [
|
"requiredPackageDependencies": [
|
||||||
{
|
{
|
||||||
"packageName": "org.gamecult.eve.surface",
|
"packageName": "org.gamecult.eve.surface",
|
||||||
"version": "0.3.0",
|
"version": "0.1.0",
|
||||||
"packageManager": "upm",
|
"packageManager": "upm",
|
||||||
"ownerRepo": "Eve",
|
"ownerRepo": "EveUnity",
|
||||||
"purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents."
|
"purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "org.gamecult.eve.unity-uitoolkit",
|
"name": "org.gamecult.eve.unity-uitoolkit",
|
||||||
"displayName": "Eve UI Toolkit Lowering",
|
"displayName": "Eve UI Toolkit Lowering",
|
||||||
"version": "0.1.9",
|
"version": "0.1.2",
|
||||||
"unity": "2021.3",
|
"unity": "2021.3",
|
||||||
"description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.",
|
"description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"org.gamecult.eve.surface": "0.3.6"
|
"org.gamecult.eve.surface": "0.2.4"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"Eve",
|
"Eve",
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ param(
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$expectedEveUnityCommit = "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
$expectedEveUnityCommit = "050b2019bd6faa4275013516870d1ded245ee391"
|
||||||
$expectedEveFieldsCommit = "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
|
$expectedEveFieldsCommit = "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
|
||||||
$expectedEveUnityUiToolkitCommit = "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
$expectedEveUnityUiToolkitCommit = "44390549775ec535ee31fc60a5bc594f372f4147"
|
||||||
$expectedCultLibCommit = "334e60f1928b4212a29dd8b0d19b2c099fe6365e"
|
$expectedCultLibCommit = "f67f5122ed1bd11da016e7b820ed60145ccd0299"
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
$projectRoot = $ClientProject
|
$projectRoot = $ClientProject
|
||||||
$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory }
|
$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory }
|
||||||
|
|
@ -40,7 +40,7 @@ $mapCapturePath = Join-Path $outputRoot "aetheria-daemon-map.png"
|
||||||
$factsPath = Join-Path $outputRoot "witness-facts.json"
|
$factsPath = Join-Path $outputRoot "witness-facts.json"
|
||||||
$providerReadyPath = Join-Path $outputRoot "provider-ready.txt"
|
$providerReadyPath = Join-Path $outputRoot "provider-ready.txt"
|
||||||
$witnessPath = Join-Path $outputRoot "runtime-witness.json"
|
$witnessPath = Join-Path $outputRoot "runtime-witness.json"
|
||||||
$clientCachePath = Join-Path $outputRoot "eve-unity-cache"
|
$replicaPath = Join-Path $outputRoot "eve-unity-replica.cc"
|
||||||
$statePath = Join-Path $outputRoot "aetheria-witness-state.cc"
|
$statePath = Join-Path $outputRoot "aetheria-witness-state.cc"
|
||||||
$providerBundleDirectory = Join-Path $AetheriaRoot "Build\EveAssets\StandaloneWindows64"
|
$providerBundleDirectory = Join-Path $AetheriaRoot "Build\EveAssets\StandaloneWindows64"
|
||||||
$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj"
|
$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj"
|
||||||
|
|
@ -86,7 +86,9 @@ $witnessProfile = if ($observedCacheState -eq "cold") {
|
||||||
}
|
}
|
||||||
$witnessStartedAt = [DateTimeOffset]::UtcNow
|
$witnessStartedAt = [DateTimeOffset]::UtcNow
|
||||||
foreach ($ephemeralPath in @(
|
foreach ($ephemeralPath in @(
|
||||||
$clientCachePath,
|
$replicaPath,
|
||||||
|
"$replicaPath.records",
|
||||||
|
"$replicaPath.cultmesh",
|
||||||
$statePath,
|
$statePath,
|
||||||
"$statePath.records",
|
"$statePath.records",
|
||||||
"$statePath.cultmesh",
|
"$statePath.cultmesh",
|
||||||
|
|
@ -209,7 +211,7 @@ $env:AETHERIA_TRACE_STARTUP_PHASES = "1"
|
||||||
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "cultnet+tcp://127.0.0.1:$Port"
|
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "cultnet+tcp://127.0.0.1:$Port"
|
||||||
$env:EVEUNITY_PROVIDER_ID = "aetheria"
|
$env:EVEUNITY_PROVIDER_ID = "aetheria"
|
||||||
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
|
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
|
||||||
$env:EVEUNITY_CACHE_DIRECTORY = $clientCachePath
|
$env:EVEUNITY_REPLICA_PATH = $replicaPath
|
||||||
$env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath
|
$env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath
|
||||||
$env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH = $mapCapturePath
|
$env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH = $mapCapturePath
|
||||||
$env:EVEUNITY_DISABLE_AUTO_LAUNCHER = "1"
|
$env:EVEUNITY_DISABLE_AUTO_LAUNCHER = "1"
|
||||||
|
|
@ -472,6 +474,6 @@ try {
|
||||||
finally {
|
finally {
|
||||||
if ($null -ne $unity -and -not $unity.HasExited) { Stop-Process -Id $unity.Id -Force -ErrorAction SilentlyContinue }
|
if ($null -ne $unity -and -not $unity.HasExited) { Stop-Process -Id $unity.Id -Force -ErrorAction SilentlyContinue }
|
||||||
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force }
|
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force }
|
||||||
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_CACHE_DIRECTORY, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH, Env:EVEUNITY_DISABLE_AUTO_LAUNCHER, Env:EVEUNITY_ASSET_CACHE_PATH, Env:EVEUNITY_WITNESS_FACTS_PATH, Env:EVEUNITY_PROVIDER_READY_PATH, Env:EVEUNITY_WITNESS_PROFILE, Env:EVEUNITY_WITNESS_GAMEPLAY_SCENARIO -ErrorAction SilentlyContinue
|
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH, Env:EVEUNITY_DISABLE_AUTO_LAUNCHER, Env:EVEUNITY_ASSET_CACHE_PATH, Env:EVEUNITY_WITNESS_FACTS_PATH, Env:EVEUNITY_PROVIDER_READY_PATH, Env:EVEUNITY_WITNESS_PROFILE, Env:EVEUNITY_WITNESS_GAMEPLAY_SCENARIO -ErrorAction SilentlyContinue
|
||||||
Remove-Item Env:AETHERIA_TRACE_EVE_SNAPSHOTS, Env:AETHERIA_TRACE_CLIENT_TRANSPORT -ErrorAction SilentlyContinue
|
Remove-Item Env:AETHERIA_TRACE_EVE_SNAPSHOTS, Env:AETHERIA_TRACE_CLIENT_TRANSPORT -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ $daemonLog = Join-Path $outputRoot "daemon.log"
|
||||||
$importLog = Join-Path $outputRoot "import.log"
|
$importLog = Join-Path $outputRoot "import.log"
|
||||||
$importErrorLog = Join-Path $outputRoot "import.error.log"
|
$importErrorLog = Join-Path $outputRoot "import.error.log"
|
||||||
$state = Join-Path $outputRoot "aetheria.cc"
|
$state = Join-Path $outputRoot "aetheria.cc"
|
||||||
$clientCache = Join-Path $outputRoot "eve-unity-cache"
|
$replica = Join-Path $outputRoot "eve-unity-replica.cc"
|
||||||
$assetCache = Join-Path $outputRoot "asset-cache"
|
$assetCache = Join-Path $outputRoot "asset-cache"
|
||||||
$exe = Join-Path $project "Build\Windows\EveUnity.exe"
|
$exe = Join-Path $project "Build\Windows\EveUnity.exe"
|
||||||
$importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj"
|
$importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj"
|
||||||
|
|
@ -32,7 +32,7 @@ if (Get-NetUDPEndpoint -LocalPort $Port -ErrorAction SilentlyContinue) {
|
||||||
throw "UDP port $Port is already occupied."
|
throw "UDP port $Port is already occupied."
|
||||||
}
|
}
|
||||||
New-Item -ItemType Directory -Force $outputRoot, $assetCache | Out-Null
|
New-Item -ItemType Directory -Force $outputRoot, $assetCache | Out-Null
|
||||||
foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $clientCache)) {
|
foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $replica, "$replica.records", "$replica.cultmesh")) {
|
||||||
$resolved = [IO.Path]::GetFullPath($ephemeralPath)
|
$resolved = [IO.Path]::GetFullPath($ephemeralPath)
|
||||||
if (-not $resolved.StartsWith([IO.Path]::GetFullPath($outputRoot), [StringComparison]::OrdinalIgnoreCase)) {
|
if (-not $resolved.StartsWith([IO.Path]::GetFullPath($outputRoot), [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
throw "Interactive cleanup escaped its artifact directory: $resolved"
|
throw "Interactive cleanup escaped its artifact directory: $resolved"
|
||||||
|
|
@ -106,7 +106,7 @@ try {
|
||||||
|
|
||||||
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port"
|
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port"
|
||||||
$env:EVEUNITY_PROVIDER_ID = "aetheria"
|
$env:EVEUNITY_PROVIDER_ID = "aetheria"
|
||||||
$env:EVEUNITY_CACHE_DIRECTORY = $clientCache
|
$env:EVEUNITY_REPLICA_PATH = $replica
|
||||||
$env:EVEUNITY_ASSET_CACHE_PATH = $assetCache
|
$env:EVEUNITY_ASSET_CACHE_PATH = $assetCache
|
||||||
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
|
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
|
||||||
$client = Start-Process $exe -ArgumentList "-force-d3d11" -PassThru
|
$client = Start-Process $exe -ArgumentList "-force-d3d11" -PassThru
|
||||||
|
|
@ -117,5 +117,5 @@ try {
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process $daemon.Id -Force }
|
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process $daemon.Id -Force }
|
||||||
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_CACHE_DIRECTORY, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue
|
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,40 @@
|
||||||
param(
|
param(
|
||||||
[string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe",
|
[string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe",
|
||||||
[string] $CultLibRoot = "",
|
[string] $CultLibRoot = "E:\Projects\CultLib",
|
||||||
[string] $EveRoot = "",
|
|
||||||
[string] $EvePluginsRoot = "",
|
|
||||||
[string] $NuGetConfig = "",
|
|
||||||
[switch] $SkipCultLibBuild,
|
|
||||||
[string] $ProjectRoot = "TestProject",
|
[string] $ProjectRoot = "TestProject",
|
||||||
[string] $OutputRoot = "artifacts\package-tests"
|
[string] $OutputRoot = "artifacts\package-tests"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
$workspaceRoot = Split-Path -Parent $repoRoot
|
|
||||||
if ([string]::IsNullOrWhiteSpace($CultLibRoot)) { $CultLibRoot = Join-Path $workspaceRoot "CultLib" }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($EveRoot)) { $EveRoot = Join-Path $workspaceRoot "Eve" }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($EvePluginsRoot)) { $EvePluginsRoot = Join-Path $workspaceRoot "EvePlugins" }
|
|
||||||
$projectPath = if ([IO.Path]::IsPathRooted($ProjectRoot)) { $ProjectRoot } else { Join-Path $repoRoot $ProjectRoot }
|
$projectPath = if ([IO.Path]::IsPathRooted($ProjectRoot)) { $ProjectRoot } else { Join-Path $repoRoot $ProjectRoot }
|
||||||
$output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot }
|
$output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot }
|
||||||
$cultLibBuilder = Join-Path $CultLibRoot "scripts\build-unity-package.ps1"
|
$cultLibBuilder = Join-Path $CultLibRoot "scripts\build-unity-package.ps1"
|
||||||
$obsoleteSurfacePackage = Join-Path $repoRoot "packages\org.gamecult.eve.surface"
|
foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) {
|
||||||
$obsoleteSurfaceSource = @("package.json", "Runtime", "Editor") |
|
|
||||||
ForEach-Object { Join-Path $obsoleteSurfacePackage $_ } |
|
|
||||||
Where-Object { Test-Path -LiteralPath $_ }
|
|
||||||
if ($obsoleteSurfaceSource.Count -gt 0) {
|
|
||||||
throw "EveUnity must consume the Eve-owned surface package; duplicate source exists at $obsoleteSurfacePackage"
|
|
||||||
}
|
|
||||||
foreach ($required in @(
|
|
||||||
$UnityExe,
|
|
||||||
$projectPath,
|
|
||||||
$cultLibBuilder,
|
|
||||||
(Join-Path $EveRoot "packages\org.gamecult.eve.surface\package.json"),
|
|
||||||
(Join-Path $EvePluginsRoot "plugins\eve-plugin-fields\unity\org.gamecult.eve.plugin-fields\package.json")
|
|
||||||
)) {
|
|
||||||
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" }
|
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $SkipCultLibBuild) {
|
powershell -ExecutionPolicy Bypass -File $cultLibBuilder
|
||||||
$cultLibBuildArguments = @("-ExecutionPolicy", "Bypass", "-File", $cultLibBuilder)
|
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
||||||
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
|
|
||||||
$cultLibBuildArguments += @("-NuGetConfig", [IO.Path]::GetFullPath($NuGetConfig))
|
|
||||||
}
|
|
||||||
& powershell @cultLibBuildArguments
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
|
||||||
}
|
|
||||||
|
|
||||||
$builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib"
|
$builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib"
|
||||||
|
$stagedDependenciesRoot = Join-Path $repoRoot "artifacts\test-dependencies"
|
||||||
|
$stagedCultLibPackage = Join-Path $stagedDependenciesRoot "org.gamecult.cultlib"
|
||||||
if (-not (Test-Path -LiteralPath $builtCultLibPackage)) {
|
if (-not (Test-Path -LiteralPath $builtCultLibPackage)) {
|
||||||
throw "CultLib Unity package build did not produce: $builtCultLibPackage"
|
throw "CultLib Unity package build did not produce: $builtCultLibPackage"
|
||||||
}
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $stagedDependenciesRoot | Out-Null
|
||||||
|
if (Test-Path -LiteralPath $stagedCultLibPackage) {
|
||||||
|
[IO.Directory]::Delete($stagedCultLibPackage, $true)
|
||||||
|
}
|
||||||
|
Copy-Item -LiteralPath $builtCultLibPackage -Destination $stagedCultLibPackage -Recurse
|
||||||
|
|
||||||
$runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss")
|
$runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss")
|
||||||
$consumerPath = Join-Path $runRoot "consumer"
|
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
|
||||||
$consumerPackagesPath = Join-Path $consumerPath "Packages"
|
|
||||||
$stagedDependenciesRoot = Join-Path $runRoot "dependencies"
|
|
||||||
New-Item -ItemType Directory -Force -Path $consumerPath, $consumerPackagesPath, $stagedDependenciesRoot | Out-Null
|
|
||||||
Copy-Item -LiteralPath (Join-Path $projectPath "Assets") -Destination (Join-Path $consumerPath "Assets") -Recurse
|
|
||||||
Copy-Item -LiteralPath (Join-Path $projectPath "ProjectSettings") -Destination (Join-Path $consumerPath "ProjectSettings") -Recurse
|
|
||||||
|
|
||||||
$dependencySources = [ordered]@{
|
|
||||||
"org.gamecult.cultlib" = $builtCultLibPackage
|
|
||||||
"org.gamecult.eve.plugin-fields" = Join-Path $EvePluginsRoot "plugins\eve-plugin-fields\unity\org.gamecult.eve.plugin-fields"
|
|
||||||
"org.gamecult.eve.surface" = Join-Path $EveRoot "packages\org.gamecult.eve.surface"
|
|
||||||
"org.gamecult.eve.unity-scene" = Join-Path $repoRoot "packages\org.gamecult.eve.unity-scene"
|
|
||||||
"org.gamecult.eve.unity-uitoolkit" = Join-Path $repoRoot "packages\org.gamecult.eve.unity-uitoolkit"
|
|
||||||
}
|
|
||||||
foreach ($entry in $dependencySources.GetEnumerator()) {
|
|
||||||
Copy-Item -LiteralPath $entry.Value -Destination (Join-Path $stagedDependenciesRoot $entry.Key) -Recurse
|
|
||||||
}
|
|
||||||
|
|
||||||
$manifest = Get-Content -LiteralPath (Join-Path $projectPath "Packages\manifest.json") -Raw | ConvertFrom-Json
|
|
||||||
foreach ($packageId in $dependencySources.Keys) {
|
|
||||||
$manifest.dependencies.$packageId = "file:../../dependencies/$packageId"
|
|
||||||
}
|
|
||||||
$manifestJson = $manifest | ConvertTo-Json -Depth 20
|
|
||||||
[IO.File]::WriteAllText(
|
|
||||||
(Join-Path $consumerPackagesPath "manifest.json"),
|
|
||||||
$manifestJson,
|
|
||||||
[Text.UTF8Encoding]::new($false))
|
|
||||||
|
|
||||||
$resultsPath = Join-Path $runRoot "unity-editmode-results.xml"
|
$resultsPath = Join-Path $runRoot "unity-editmode-results.xml"
|
||||||
$logPath = Join-Path $runRoot "unity-editmode.log"
|
$logPath = Join-Path $runRoot "unity-editmode.log"
|
||||||
$arguments = @(
|
$arguments = @(
|
||||||
"-batchmode", "-projectPath", $consumerPath,
|
"-batchmode", "-projectPath", $projectPath,
|
||||||
"-runTests", "-testPlatform", "EditMode",
|
"-runTests", "-testPlatform", "EditMode",
|
||||||
"-assemblyNames", "GameCult.Eve.UnityScene.Tests",
|
"-assemblyNames", "GameCult.Eve.UnityScene.Tests",
|
||||||
"-testResults", $resultsPath, "-logFile", $logPath
|
"-testResults", $resultsPath, "-logFile", $logPath
|
||||||
|
|
@ -109,5 +62,5 @@ if ($null -eq $run -or [int]$run.total -eq 0 -or [int]$run.failed -gt 0) {
|
||||||
throw "EveUnity package tests did not pass: $resultsPath"
|
throw "EveUnity package tests did not pass: $resultsPath"
|
||||||
}
|
}
|
||||||
Write-Host "EveUnity generic package tests: $($run.passed) passed, $($run.total) total"
|
Write-Host "EveUnity generic package tests: $($run.passed) passed, $($run.total) total"
|
||||||
Write-Host "Isolated consumer: $consumerPath"
|
Write-Host "Consumer: $projectPath"
|
||||||
Write-Host "Staged dependencies: $stagedDependenciesRoot"
|
Write-Host "CultLib package: $stagedCultLibPackage"
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,9 @@ param(
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
$sourceProjectPath = Join-Path $repoRoot "ReleaseConsumerProject"
|
$projectPath = "ReleaseConsumerProject"
|
||||||
$results = if ([IO.Path]::IsPathRooted($ResultsPath)) { $ResultsPath } else { Join-Path $repoRoot $ResultsPath }
|
$results = if ([IO.Path]::IsPathRooted($ResultsPath)) { $ResultsPath } else { Join-Path $repoRoot $ResultsPath }
|
||||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $results) | Out-Null
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $results) | Out-Null
|
||||||
$runRoot = Join-Path (Split-Path -Parent $results) ("release-consumer-" + (Get-Date -Format "yyyyMMddTHHmmss"))
|
|
||||||
$projectPath = Join-Path $runRoot "consumer"
|
|
||||||
New-Item -ItemType Directory -Force -Path (Join-Path $projectPath "Packages") | Out-Null
|
|
||||||
Copy-Item -LiteralPath (Join-Path $sourceProjectPath "Assets") -Destination (Join-Path $projectPath "Assets") -Recurse
|
|
||||||
Copy-Item -LiteralPath (Join-Path $sourceProjectPath "ProjectSettings") -Destination (Join-Path $projectPath "ProjectSettings") -Recurse
|
|
||||||
Copy-Item -LiteralPath (Join-Path $sourceProjectPath "Packages\manifest.json") `
|
|
||||||
-Destination (Join-Path $projectPath "Packages\manifest.json")
|
|
||||||
$logPath = [IO.Path]::ChangeExtension($results, ".log")
|
$logPath = [IO.Path]::ChangeExtension($results, ".log")
|
||||||
$arguments = @(
|
$arguments = @(
|
||||||
"-batchmode", "-projectPath", $projectPath,
|
"-batchmode", "-projectPath", $projectPath,
|
||||||
|
|
@ -22,12 +15,7 @@ $arguments = @(
|
||||||
"-testResults", $results, "-logFile", $logPath
|
"-testResults", $results, "-logFile", $logPath
|
||||||
)
|
)
|
||||||
|
|
||||||
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden
|
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -WorkingDirectory $repoRoot -Wait -PassThru -WindowStyle Hidden
|
||||||
if (-not $process.WaitForExit([int][TimeSpan]::FromMinutes(10).TotalMilliseconds)) {
|
|
||||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
|
||||||
if (Test-Path $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
|
||||||
throw "Released EveUnity package consumer tests timed out after 10 minutes (Unity PID $($process.Id))."
|
|
||||||
}
|
|
||||||
if ($process.ExitCode -ne 0) {
|
if ($process.ExitCode -ne 0) {
|
||||||
if (Test-Path $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
if (Test-Path $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
||||||
throw "Released EveUnity package consumer tests failed with exit code $($process.ExitCode)"
|
throw "Released EveUnity package consumer tests failed with exit code $($process.ExitCode)"
|
||||||
|
|
@ -38,4 +26,3 @@ if (-not (Test-Path $results)) { throw "Unity did not produce release consumer t
|
||||||
$result = $report.'test-run'.result
|
$result = $report.'test-run'.result
|
||||||
if ($result -ne "Passed") { throw "Released EveUnity package consumer result was '$result'." }
|
if ($result -ne "Passed") { throw "Released EveUnity package consumer result was '$result'." }
|
||||||
Write-Host "Released EveUnity package consumer passed: $results"
|
Write-Host "Released EveUnity package consumer passed: $results"
|
||||||
Write-Host "Isolated git consumer: $projectPath"
|
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,9 @@ foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) {
|
||||||
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity UI Toolkit test path not found: $required" }
|
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity UI Toolkit test path not found: $required" }
|
||||||
}
|
}
|
||||||
|
|
||||||
& $cultLibBuilder
|
powershell -ExecutionPolicy Bypass -File $cultLibBuilder
|
||||||
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
||||||
|
|
||||||
$builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib"
|
|
||||||
$stagedDependenciesRoot = Join-Path $repoRoot "artifacts\test-dependencies"
|
|
||||||
$stagedCultLibPackage = Join-Path $stagedDependenciesRoot "org.gamecult.cultlib"
|
|
||||||
if (-not (Test-Path -LiteralPath $builtCultLibPackage)) {
|
|
||||||
throw "CultLib Unity package build did not produce: $builtCultLibPackage"
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Force -Path $stagedDependenciesRoot | Out-Null
|
|
||||||
if (Test-Path -LiteralPath $stagedCultLibPackage) {
|
|
||||||
[IO.Directory]::Delete($stagedCultLibPackage, $true)
|
|
||||||
}
|
|
||||||
Copy-Item -LiteralPath $builtCultLibPackage -Destination $stagedCultLibPackage -Recurse
|
|
||||||
|
|
||||||
$runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss")
|
$runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss")
|
||||||
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
|
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
|
||||||
$resultsPath = Join-Path $runRoot "unity-editmode-results.xml"
|
$resultsPath = Join-Path $runRoot "unity-editmode-results.xml"
|
||||||
|
|
@ -39,16 +27,10 @@ $arguments = @(
|
||||||
"-assemblyNames", "GameCult.Eve.UnityUIToolkit.Tests",
|
"-assemblyNames", "GameCult.Eve.UnityUIToolkit.Tests",
|
||||||
"-testResults", $resultsPath, "-logFile", $logPath
|
"-testResults", $resultsPath, "-logFile", $logPath
|
||||||
)
|
)
|
||||||
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -PassThru -WindowStyle Hidden
|
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden
|
||||||
if (-not $process.WaitForExit([int][TimeSpan]::FromMinutes(10).TotalMilliseconds)) {
|
if ($process.ExitCode -ne 0) {
|
||||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
|
||||||
if (Test-Path -LiteralPath $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
if (Test-Path -LiteralPath $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
||||||
throw "EveUnity UI Toolkit tests timed out after 10 minutes (Unity PID $($process.Id))."
|
throw "EveUnity UI Toolkit tests failed with exit code $($process.ExitCode)"
|
||||||
}
|
|
||||||
$unityExitCode = $process.ExitCode
|
|
||||||
if ($unityExitCode -ne 0) {
|
|
||||||
if (Test-Path -LiteralPath $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
|
||||||
throw "EveUnity UI Toolkit tests failed with exit code $unityExitCode"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[xml]$results = Get-Content -LiteralPath $resultsPath -Raw
|
[xml]$results = Get-Content -LiteralPath $resultsPath -Raw
|
||||||
|
|
|
||||||
1
tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
vendored
Normal file
1
tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
!GameCult.Eve.Surface.Serialization.Tests.csproj
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using GameCult.Eve.Surface;
|
||||||
|
using GameCult.Mesh;
|
||||||
|
using MessagePack;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace GameCult.Eve.Surface.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class EveSurfaceSerializationCompatibilityTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void EntitySoaV2SerializesLogicalLayoutWithoutTransportFields()
|
||||||
|
{
|
||||||
|
var document = new EveEntitySoaViewDocument
|
||||||
|
{
|
||||||
|
ProviderId = "provider",
|
||||||
|
ViewId = "entities",
|
||||||
|
BodySchemaId = "entity.slab.v1",
|
||||||
|
LayoutVersion = 2,
|
||||||
|
ProducerEpoch = 3,
|
||||||
|
Sequence = 4,
|
||||||
|
Capacity = 8,
|
||||||
|
FrameId = 91,
|
||||||
|
Buffers = new[] { new EveEntitySoaBuffer { BufferId = "entity-hot", ByteLength = 64 } },
|
||||||
|
Identities = new[]
|
||||||
|
{
|
||||||
|
new EveEntityIdentity
|
||||||
|
{
|
||||||
|
Index = 0,
|
||||||
|
EntityId = "player",
|
||||||
|
EntityKind = "ship",
|
||||||
|
Label = "Vanguard",
|
||||||
|
Faction = "alliance",
|
||||||
|
Selectable = true,
|
||||||
|
Controllable = true,
|
||||||
|
AssetRef = "cultmesh://assets/player"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var bytes = MessagePackSerializer.Serialize(document, Options);
|
||||||
|
var reader = new MessagePackReader(bytes);
|
||||||
|
var restored = MessagePackSerializer.Deserialize<EveEntitySoaViewDocument>(bytes, Options);
|
||||||
|
|
||||||
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(15));
|
||||||
|
Assert.That(restored.Schema, Is.EqualTo(EveEntitySoaViewDocument.SchemaId));
|
||||||
|
Assert.That(restored.Buffers[0].BufferId, Is.EqualTo("entity-hot"));
|
||||||
|
Assert.That(restored.FrameId, Is.EqualTo(91));
|
||||||
|
Assert.That(restored.Identities[0].Label, Is.EqualTo("Vanguard"));
|
||||||
|
Assert.That(restored.Identities[0].Faction, Is.EqualTo("alliance"));
|
||||||
|
Assert.That(restored.Identities[0].Selectable, Is.True);
|
||||||
|
Assert.That(restored.Identities[0].Controllable, Is.True);
|
||||||
|
Assert.That(restored.Identities[0].AssetRef, Is.EqualTo("cultmesh://assets/player"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void EntitySoaContractsExposeNoTransportAuthorityFields()
|
||||||
|
{
|
||||||
|
var forbidden = new[] { "Backend", "Location", "CapabilityToken", "Synchronization", "SynchronizationMode", "Descriptor" };
|
||||||
|
var exposed = new[] { typeof(EveEntitySoaViewDocument), typeof(EveEntitySoaBuffer), typeof(EveEntitySoaColumn) }
|
||||||
|
.SelectMany(type => type.GetProperties())
|
||||||
|
.Select(property => property.Name);
|
||||||
|
|
||||||
|
Assert.That(exposed, Has.None.Matches<string>(name => forbidden.Contains(name, StringComparer.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void LegacySevenFieldSurfaceDocumentDeserializes()
|
||||||
|
{
|
||||||
|
var bytes = WriteLegacySurfaceDocument();
|
||||||
|
|
||||||
|
var document = MessagePackSerializer.Deserialize<EveSurfaceDocument>(bytes, Options);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(document.Type, Is.EqualTo(EveSurfaceDocument.DefaultType));
|
||||||
|
Assert.That(document.Schema, Is.EqualTo(EveSurfaceDocument.SchemaId));
|
||||||
|
Assert.That(document.ProviderId, Is.EqualTo("legacy-provider"));
|
||||||
|
Assert.That(document.ProviderKind, Is.EqualTo("legacy-kind"));
|
||||||
|
Assert.That(document.Title, Is.EqualTo("Legacy surface"));
|
||||||
|
Assert.That(document.Version, Is.EqualTo(17));
|
||||||
|
Assert.That(document.UpdatedAtUtc, Is.EqualTo("2026-07-13T12:00:00Z"));
|
||||||
|
Assert.That(document.Surface.Id, Is.EqualTo("legacy-surface"));
|
||||||
|
Assert.That(document.Commands, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(document.Commands[0].Command, Is.EqualTo("legacy.execute"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void LegacyDirectFiveStringCommandTemplateDeserializes()
|
||||||
|
{
|
||||||
|
var bytes = WriteLegacyCommandTemplate();
|
||||||
|
|
||||||
|
var command = MessagePackSerializer.Deserialize<EveCommandTemplate>(bytes, Options);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(command.Command, Is.EqualTo("legacy.execute"));
|
||||||
|
Assert.That(command.Label, Is.EqualTo("Execute legacy command"));
|
||||||
|
Assert.That(command.Operation.SchemaId, Is.EqualTo("gamecult.legacy.command.v1"));
|
||||||
|
Assert.That(command.OperationRecord.RouteKind, Is.EqualTo("cultmesh"));
|
||||||
|
Assert.That(command.OperationRecord.RouteDescription, Is.EqualTo("legacy.commands"));
|
||||||
|
Assert.That(command.Transport, Is.EqualTo("legacy.commands"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding()
|
||||||
|
{
|
||||||
|
var document = CreateCurrentDocument();
|
||||||
|
|
||||||
|
var bytes = MessagePackSerializer.Serialize(document, Options);
|
||||||
|
var reader = new MessagePackReader(bytes);
|
||||||
|
|
||||||
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9));
|
||||||
|
for (var field = 0; field < 8; field++)
|
||||||
|
reader.Skip();
|
||||||
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "commands collection");
|
||||||
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "command template wrapper");
|
||||||
|
Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Array), "structured operation binding");
|
||||||
|
|
||||||
|
var roundTrip = MessagePackSerializer.Deserialize<EveSurfaceDocument>(bytes, Options);
|
||||||
|
Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void LegacyExpandedProviderAdvertisementDeserializesAsCanonicalContract()
|
||||||
|
{
|
||||||
|
var document = MessagePackSerializer.Deserialize<EveProviderAdvertisementDocument>(
|
||||||
|
WriteLegacyProviderAdvertisement(),
|
||||||
|
Options);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(document.ProviderId, Is.EqualTo("aetheria"));
|
||||||
|
Assert.That(document.ServiceId, Is.EqualTo("aetheria-daemon"));
|
||||||
|
Assert.That(document.VerseId, Is.EqualTo("aetheria.local"));
|
||||||
|
Assert.That(document.Title, Is.EqualTo("Aetheria"));
|
||||||
|
Assert.That(document.CultMeshAddress, Is.EqualTo("cultmesh://aetheria.local/aetheria"));
|
||||||
|
Assert.That(document.Surfaces[0].SurfaceId, Is.EqualTo("aetheria.pilot"));
|
||||||
|
Assert.That(document.Surfaces[0].Schema, Is.EqualTo(EveSurfaceDocument.SchemaId));
|
||||||
|
Assert.That(document.Commands[0].Command, Is.EqualTo("aetheria.pilot.move"));
|
||||||
|
Assert.That(document.Commands[0].SurfaceId, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CurrentProviderAdvertisementSerializationWritesCanonicalThirteenFields()
|
||||||
|
{
|
||||||
|
var document = new EveProviderAdvertisementDocument(
|
||||||
|
"provider", "service", "verse", "Title", "game.runtime", "cultmesh://verse/provider",
|
||||||
|
"2026-07-13T14:00:00Z", new EveProviderFreshness("fresh", "2026-07-13T14:00:00Z", 15000),
|
||||||
|
new[] { EveSurfaceDocument.SchemaId }, Array.Empty<EveProviderWitness>(),
|
||||||
|
Array.Empty<EveAdvertisedSurface>(), Array.Empty<EveAdvertisedCommand>());
|
||||||
|
|
||||||
|
var bytes = MessagePackSerializer.Serialize(document, Options);
|
||||||
|
var reader = new MessagePackReader(bytes);
|
||||||
|
|
||||||
|
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(13));
|
||||||
|
Assert.That(MessagePackSerializer.Deserialize<EveProviderAdvertisementDocument>(bytes, Options).Title,
|
||||||
|
Is.EqualTo("Title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EveSurfaceDocument CreateCurrentDocument() => new(
|
||||||
|
"current-provider",
|
||||||
|
"current-kind",
|
||||||
|
"Current surface",
|
||||||
|
23,
|
||||||
|
"2026-07-13T13:00:00Z",
|
||||||
|
CreateSurfaceTree("current-surface"),
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new EveCommandTemplate(new CultMeshOperationBindingRecord(
|
||||||
|
"current.execute",
|
||||||
|
"Execute current command",
|
||||||
|
"gamecult.current.command.v1",
|
||||||
|
"cultmesh",
|
||||||
|
"current.commands"))
|
||||||
|
});
|
||||||
|
|
||||||
|
private static EveSurfaceTree CreateSurfaceTree(string id) => new(
|
||||||
|
id,
|
||||||
|
new EveSurfaceComponent(
|
||||||
|
"root",
|
||||||
|
"column",
|
||||||
|
new Dictionary<string, string>(),
|
||||||
|
Array.Empty<EveSurfaceComponent>()),
|
||||||
|
Array.Empty<EveStyleToken>());
|
||||||
|
|
||||||
|
private static byte[] WriteLegacySurfaceDocument()
|
||||||
|
{
|
||||||
|
var buffer = new ArrayBufferWriter<byte>();
|
||||||
|
var writer = new MessagePackWriter(buffer);
|
||||||
|
writer.WriteArrayHeader(7);
|
||||||
|
writer.Write("legacy-provider");
|
||||||
|
writer.Write("legacy-kind");
|
||||||
|
writer.Write("Legacy surface");
|
||||||
|
writer.Write(17L);
|
||||||
|
writer.Write("2026-07-13T12:00:00Z");
|
||||||
|
writer.WriteRaw(MessagePackSerializer.Serialize(CreateSurfaceTree("legacy-surface"), Options));
|
||||||
|
writer.WriteArrayHeader(1);
|
||||||
|
writer.WriteRaw(WriteLegacyCommandTemplate());
|
||||||
|
writer.Flush();
|
||||||
|
return buffer.WrittenSpan.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] WriteLegacyCommandTemplate()
|
||||||
|
{
|
||||||
|
var buffer = new ArrayBufferWriter<byte>();
|
||||||
|
var writer = new MessagePackWriter(buffer);
|
||||||
|
writer.WriteArrayHeader(5);
|
||||||
|
writer.Write("legacy.execute");
|
||||||
|
writer.Write("Execute legacy command");
|
||||||
|
writer.Write("gamecult.legacy.command.v1");
|
||||||
|
writer.Write("cultmesh");
|
||||||
|
writer.Write("legacy.commands");
|
||||||
|
writer.Flush();
|
||||||
|
return buffer.WrittenSpan.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] WriteLegacyProviderAdvertisement()
|
||||||
|
{
|
||||||
|
var buffer = new ArrayBufferWriter<byte>();
|
||||||
|
var writer = new MessagePackWriter(buffer);
|
||||||
|
writer.WriteArrayHeader(16);
|
||||||
|
writer.Write(EveProviderAdvertisementDocument.SchemaId);
|
||||||
|
writer.Write("aetheria");
|
||||||
|
writer.Write("aetheria-daemon");
|
||||||
|
writer.Write("aetheria.local");
|
||||||
|
writer.Write("asgard");
|
||||||
|
writer.Write("aetheria");
|
||||||
|
writer.Write("aetheria.local/aetheria");
|
||||||
|
writer.Write("cultmesh://aetheria.local/aetheria");
|
||||||
|
writer.Write("Aetheria");
|
||||||
|
writer.Write("game.runtime");
|
||||||
|
writer.Write("2026-07-13T14:00:00Z");
|
||||||
|
writer.WriteArrayHeader(3);
|
||||||
|
writer.Write("fresh");
|
||||||
|
writer.Write("2026-07-13T14:00:00Z");
|
||||||
|
writer.Write(15000);
|
||||||
|
writer.WriteArrayHeader(1);
|
||||||
|
writer.Write(EveSurfaceDocument.SchemaId);
|
||||||
|
writer.WriteArrayHeader(0);
|
||||||
|
writer.WriteArrayHeader(1);
|
||||||
|
writer.WriteArrayHeader(5);
|
||||||
|
writer.Write(EveSurfaceDocument.SchemaId);
|
||||||
|
writer.Write("aetheria.pilot");
|
||||||
|
writer.Write("eve:surface:aetheria.pilot");
|
||||||
|
writer.Write("cultmesh");
|
||||||
|
writer.Write("available");
|
||||||
|
writer.WriteArrayHeader(1);
|
||||||
|
writer.WriteArrayHeader(3);
|
||||||
|
writer.Write("aetheria.pilot.move");
|
||||||
|
writer.Write("cultmesh");
|
||||||
|
writer.Write("Move the pilot ship");
|
||||||
|
writer.Flush();
|
||||||
|
return buffer.WrittenSpan.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<BaseOutputPath>obj\bin\</BaseOutputPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||||
|
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in a new issue