Compare commits
31 changed files with 475 additions and 3725 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,6 +9,3 @@ TestProject/UserSettings/
|
|||
obj/
|
||||
*.csproj
|
||||
*.sln
|
||||
packages/org.gamecult.eve.surface/bin~/
|
||||
packages/org.gamecult.eve.surface/obj~/
|
||||
packages/org.gamecult.eve.surface/Directory.Build.props.meta
|
||||
|
|
|
|||
|
|
@ -310,8 +310,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
|
||||
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
|
||||
rendezvousEndpoint,
|
||||
provider.Selection.VerseId,
|
||||
provider.Selection.AuthorityRuntimeId);
|
||||
provider.Selection.VerseId);
|
||||
var quicDeadline = Time.realtimeSinceStartup + 15f;
|
||||
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
|
||||
yield return new WaitForSecondsRealtime(0.05f);
|
||||
|
|
@ -1201,8 +1200,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
|
||||
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
|
||||
string rendezvousEndpoint,
|
||||
string verseId,
|
||||
string authorityRuntimeId)
|
||||
string verseId)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var client = new CultMeshClient(new CultMeshClientOptions
|
||||
|
|
@ -1213,9 +1211,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
new CultMeshNativeQuicRealtimeTransportConnector()
|
||||
}
|
||||
});
|
||||
using var session = await client.ConnectRealtimeAsync(
|
||||
new CultMeshSessionTarget(verseId, authorityRuntimeId),
|
||||
timeout.Token);
|
||||
using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token);
|
||||
return await session.ReceiveAsync(timeout.Token);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
"com.unity.modules.physics": "1.0.0",
|
||||
"com.unity.modules.uielements": "1.0.0",
|
||||
"com.unity.render-pipelines.universal": "17.4.0",
|
||||
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
|
||||
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45",
|
||||
"org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
|
||||
"org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
|
||||
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9",
|
||||
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
||||
"org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0",
|
||||
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9",
|
||||
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9"
|
||||
},
|
||||
"testables": [
|
||||
"org.gamecult.eve.unity-scene",
|
||||
|
|
|
|||
|
|
@ -119,11 +119,11 @@
|
|||
}
|
||||
},
|
||||
"org.gamecult.cultlib": {
|
||||
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
|
||||
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {},
|
||||
"hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e"
|
||||
"hash": "03840d0430bc727f1322861a1ab46b396eaca860"
|
||||
},
|
||||
"org.gamecult.eve.plugin-fields": {
|
||||
"version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
|
||||
|
|
@ -135,37 +135,37 @@
|
|||
"hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
|
||||
},
|
||||
"org.gamecult.eve.surface": {
|
||||
"version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
|
||||
"version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {
|
||||
"org.gamecult.cultlib": "1.0.56"
|
||||
"org.gamecult.cultlib": "1.0.45"
|
||||
},
|
||||
"hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825"
|
||||
"hash": "fe0dddf28267decbb416325a3d0b2c62432825c2"
|
||||
},
|
||||
"org.gamecult.eve.unity-scene": {
|
||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9",
|
||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {
|
||||
"org.gamecult.cultlib": "1.0.56",
|
||||
"org.gamecult.cultlib": "1.0.45",
|
||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||
"org.gamecult.eve.surface": "0.3.6",
|
||||
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
|
||||
"org.gamecult.eve.surface": "0.3.0",
|
||||
"org.gamecult.eve.unity-uitoolkit": "0.1.3",
|
||||
"com.unity.mathematics": "1.3.2",
|
||||
"com.unity.render-pipelines.universal": "17.4.0",
|
||||
"com.unity.modules.particlesystem": "1.0.0"
|
||||
},
|
||||
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
||||
"hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9"
|
||||
},
|
||||
"org.gamecult.eve.unity-uitoolkit": {
|
||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9",
|
||||
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {
|
||||
"org.gamecult.eve.surface": "0.3.6"
|
||||
"org.gamecult.eve.surface": "0.3.0"
|
||||
},
|
||||
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
||||
"hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9"
|
||||
},
|
||||
"com.unity.modules.assetbundle": {
|
||||
"version": "1.0.0",
|
||||
|
|
|
|||
|
|
@ -310,8 +310,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
|
||||
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
|
||||
rendezvousEndpoint,
|
||||
provider.Selection.VerseId,
|
||||
provider.Selection.AuthorityRuntimeId);
|
||||
provider.Selection.VerseId);
|
||||
var quicDeadline = Time.realtimeSinceStartup + 15f;
|
||||
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
|
||||
yield return new WaitForSecondsRealtime(0.05f);
|
||||
|
|
@ -1201,8 +1200,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
|
||||
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
|
||||
string rendezvousEndpoint,
|
||||
string verseId,
|
||||
string authorityRuntimeId)
|
||||
string verseId)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var client = new CultMeshClient(new CultMeshClientOptions
|
||||
|
|
@ -1213,9 +1211,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
new CultMeshNativeQuicRealtimeTransportConnector()
|
||||
}
|
||||
});
|
||||
using var session = await client.ConnectRealtimeAsync(
|
||||
new CultMeshSessionTarget(verseId, authorityRuntimeId),
|
||||
timeout.Token);
|
||||
using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token);
|
||||
return await session.ReceiveAsync(timeout.Token);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@
|
|||
"depth": 0,
|
||||
"source": "local",
|
||||
"dependencies": {
|
||||
"org.gamecult.cultlib": "1.0.56"
|
||||
"org.gamecult.cultlib": "1.0.45"
|
||||
}
|
||||
},
|
||||
"org.gamecult.eve.unity-scene": {
|
||||
|
|
@ -145,10 +145,10 @@
|
|||
"depth": 0,
|
||||
"source": "local",
|
||||
"dependencies": {
|
||||
"org.gamecult.cultlib": "1.0.56",
|
||||
"org.gamecult.cultlib": "1.0.45",
|
||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||
"org.gamecult.eve.surface": "0.3.6",
|
||||
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
|
||||
"org.gamecult.eve.surface": "0.3.0",
|
||||
"org.gamecult.eve.unity-uitoolkit": "0.1.3",
|
||||
"com.unity.mathematics": "1.3.2",
|
||||
"com.unity.render-pipelines.universal": "17.4.0",
|
||||
"com.unity.modules.particlesystem": "1.0.0"
|
||||
|
|
@ -159,7 +159,7 @@
|
|||
"depth": 0,
|
||||
"source": "local",
|
||||
"dependencies": {
|
||||
"org.gamecult.eve.surface": "0.3.6"
|
||||
"org.gamecult.eve.surface": "0.3.0"
|
||||
}
|
||||
},
|
||||
"com.unity.modules.assetbundle": {
|
||||
|
|
|
|||
|
|
@ -4,26 +4,26 @@
|
|||
|
||||
The generic `ReleaseConsumerProject` is pinned to released Git packages:
|
||||
|
||||
- `org.gamecult.eve.unity-scene` `0.3.134`, commit
|
||||
`d1663b36d12b60ff5c7249135eff48edf6227ca9`, and
|
||||
`org.gamecult.eve.surface` `0.3.6`, commit
|
||||
`96839ad34c8d464ef622d8bbdd5d277e1ca9d825`;
|
||||
- `org.gamecult.eve.unity-scene` `0.3.100`, commit
|
||||
`3632bfb3354065b78a90b921d7025c4c3fa92be4`, and
|
||||
`org.gamecult.eve.surface` `0.2.4`, commit
|
||||
`e08fa08335f99e9edddeb706912eecfad07cb281`;
|
||||
- `org.gamecult.eve.plugin-fields` `0.2.3`, commit
|
||||
`c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`;
|
||||
- `org.gamecult.eve.unity-uitoolkit` `0.1.9`, commit
|
||||
`d1663b36d12b60ff5c7249135eff48edf6227ca9`;
|
||||
- `org.gamecult.cultlib` `1.0.56`, commit
|
||||
`334e60f1928b4212a29dd8b0d19b2c099fe6365e`.
|
||||
- `org.gamecult.eve.unity-uitoolkit` `0.1.1`, commit
|
||||
`4d0cbe0185bdc4fc65eb63503a7c5cb578539669`;
|
||||
- `org.gamecult.cultlib` `1.0.43`, commit
|
||||
`f67f5122ed1bd11da016e7b820ed60145ccd0299`.
|
||||
|
||||
The hand-run integration gates are the cold lowering proof and the warm
|
||||
full-session proof:
|
||||
|
||||
```powershell
|
||||
pwsh -File .\scripts\run-aetheria-daemon-world-witness.ps1 `
|
||||
-CultLibRoot E:\Projects\CultLib `
|
||||
-CultLibRoot E:\Projects\CultLib-codex-cultmesh-reliability `
|
||||
-CacheState cold `
|
||||
-AssetCacheDirectory artifacts\admitted-release-cold-cache `
|
||||
-OutputDirectory artifacts\admitted-release-cold `
|
||||
-AssetCacheDirectory artifacts\cultlib-1043-scene-03100-cold-cache `
|
||||
-OutputDirectory artifacts\cultlib-1043-scene-03100-cold `
|
||||
-SkipAssetBundleBuild
|
||||
```
|
||||
|
||||
|
|
@ -31,13 +31,9 @@ Both connect directly to the Aetheria daemon. Control, subscriptions, commands,
|
|||
and receipts use `cultnet+tcp`; provider files use `cultmesh-content+tcp`;
|
||||
same-machine entity state uses a mapped SoA body; and the released native QUIC
|
||||
client receives the advertised latest-only realtime body. No Odin hop or
|
||||
Aetheria-specific gameplay code participates in the Unity runtime. This
|
||||
admission pass proves the package graph with the isolated EditMode consumer; it
|
||||
does not claim a new rendered end-to-end witness.
|
||||
Aetheria-specific gameplay code participates in the Unity runtime.
|
||||
|
||||
## Historical witness ledger
|
||||
|
||||
The retained cold proof is
|
||||
The current cold proof is
|
||||
`artifacts/cultlib-1043-scene-03100-cold5/runtime-witness.cold.json`. It began
|
||||
with no bodies or partials and passed in 57.8 seconds end to end (14.9 seconds
|
||||
inside PlayMode). The client materialized nine requested bundles totaling
|
||||
|
|
@ -49,7 +45,7 @@ unloaded. This cold profile owns content transfer, world lowering, fog and
|
|||
Stardust execution, and pilot/map camera isolation. It deliberately issues no
|
||||
gameplay command into the paused Terminus fixture.
|
||||
|
||||
The retained warm full-session proof is
|
||||
The current warm full-session proof is
|
||||
`artifacts/cultlib-1043-scene-03100-warm/runtime-witness.warm.json`. It passed in
|
||||
70.7 seconds end to end (27.9 seconds inside PlayMode). The generic client docks,
|
||||
buys and sells one typed item, resumes and undocks, moves, targets, aims, holds
|
||||
|
|
@ -67,6 +63,8 @@ visual-lever and shader-calibration issues, not concealed transport failures.
|
|||
The map capture is clean and contains only map-channel glyphs on its dark map
|
||||
background.
|
||||
|
||||
## Historical witness ledger
|
||||
|
||||
An earlier released-package cold world witness passed with:
|
||||
|
||||
- `org.gamecult.eve.unity-scene` `0.3.63`, commit
|
||||
|
|
|
|||
|
|
@ -76,13 +76,6 @@ Unity code may not open provider snapshot sessions, schema clients, write
|
|||
forwarders, subscriptions, or replica shards against a discovered physical
|
||||
route. Reads, watches, content/body transfer, realtime frames, and command
|
||||
submissions share the same stable-identity `CultMeshClient` owner.
|
||||
Remote bootstrap and asset materialization complete through `PrepareAsync`
|
||||
before the scene is mounted. `Connect`, `Mount`, input, refresh, and disposal
|
||||
cannot synchronously wait on CultMesh. A bounded command outbox owns delivery;
|
||||
continuous movement/look values may be replaced by a newer value, but transport
|
||||
send completion cannot retire any command. Only the provider's canonical
|
||||
receipt retires its idempotency key. Unacknowledged work is retried fairly; it
|
||||
cannot become client-authored success or starve unrelated commands.
|
||||
|
||||
Viewport-sized shader inputs are likewise presentation state. A provider may
|
||||
relate a logical vector port to a logical native texture port through
|
||||
|
|
@ -105,10 +98,8 @@ modes into one camera opinion or infer a product camera from entity kind.
|
|||
|
||||
## Shared Paths
|
||||
|
||||
Initial preparation asks one rendezvous for Verses, selects an advertised
|
||||
authority runtime and provider surface by semantic kind, then leases it through
|
||||
the explicit Verse/authority-runtime target. The Eve provider ID remains a
|
||||
separate surface-owner identity.
|
||||
Initial connect asks one rendezvous for Verses, selects a provider surface by
|
||||
semantic kind, then leases it through the selected stable provider identity.
|
||||
Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain
|
||||
inside the same identity-owned session. Movement, focus, target, and action
|
||||
input all become `gamecult.eve.command_invocation.v1` through the
|
||||
|
|
|
|||
|
|
@ -16,47 +16,20 @@ owned by their native lowerers and are not duplicated into empty UI elements.
|
|||
networked client. Give it one Verse rendezvous endpoint and it discovers an
|
||||
advertised `interactive-world`, constructs the generic CultMesh transport, and
|
||||
provides every bootstrap port. Provider, surface, and Verse IDs are optional
|
||||
selection filters; they are not product knowledge required by the client. The
|
||||
selected Verse ID and its advertised authority-runtime ID form the stable
|
||||
CultMesh session target. The Eve provider ID separately names the UI owner found
|
||||
through that target. Physical routes are resolved and re-resolved inside
|
||||
`CultMeshClient`; they are never published as provider selection state or
|
||||
cached as Unity authority. The optional local path is only an ephemeral
|
||||
content/body cache, not a replicated gameplay database.
|
||||
selection filters; they are not product knowledge required by the client.
|
||||
The selected provider ID remains the stable CultMesh endpoint identity while
|
||||
the Verse ID names the selected rule/progression world. Physical routes are
|
||||
resolved and re-resolved inside `CultMeshClient`; they are never
|
||||
published as provider selection state or cached as Unity authority. The optional
|
||||
local path is only an ephemeral content/body cache, not a replicated gameplay
|
||||
database.
|
||||
Await `PrepareAsync()` before mounting the bootstrap or reading provider ports.
|
||||
It owns discovery, initial document reads, content transfer, asynchronous asset
|
||||
bundle loading, and live lease installation. Once preparation succeeds,
|
||||
`Mount()` and `Connect()` perform no remote work. Input submission only enters a
|
||||
bounded asynchronous outbox: one-shot commands retain their idempotency keys,
|
||||
while movement and look values coalesce to the latest value per entity. A
|
||||
successful send is not completion: the command remains pending and retries with
|
||||
the same idempotency key until its canonical provider receipt arrives. Failed
|
||||
or unacknowledged commands rotate behind other due work, so one poison command
|
||||
cannot starve the outbox.
|
||||
Live entity, field, and receipt polling likewise remains non-blocking. Unity's
|
||||
main thread applies completed typed documents and assets; it never waits for a
|
||||
CultMesh task, keeping Play, Pause, input, inspection, and rendering responsive
|
||||
while a provider is slow or temporarily unreachable.
|
||||
|
||||
An Eve world may qualify its asset catalog with a different provider, Verse,
|
||||
authority runtime, and Odin rendezvous set than the surface provider. The
|
||||
transport resolves that exact asset session and commits assets as one generation:
|
||||
catalog, verified bundles, leases, lookups, render policy, and surface lowering
|
||||
are prepared off to the side, then swapped together. A failed or superseded
|
||||
candidate is disposed without unloading or rewriting the mounted generation.
|
||||
The mounted provider's trust policy authenticates only that provider. A
|
||||
provider-qualified asset source in another Verse/runtime uses the configured
|
||||
cross-target Odin trust for its catalog, subscription, manifests, content, and
|
||||
bundles; local-development trust does not cross that boundary.
|
||||
The candidate opens its catalog lease before preload, observes updates in a
|
||||
candidate-owned buffer, and verifies the leased version again before commit.
|
||||
An update that lands during preload therefore rebuilds the candidate instead
|
||||
of disappearing between a one-shot read and a later subscription.
|
||||
|
||||
Navigation keeps the outgoing presentation quiesced until the candidate route
|
||||
is ready, then commits the provider route before activating the candidate.
|
||||
Presentation connections are provider-owned leases: disposing the outgoing
|
||||
host releases only its lease and cannot disconnect the newly committed Verse.
|
||||
Discovery and session setup never run synchronously from Unity lifecycle or
|
||||
input getters, so an unavailable provider cannot stall the editor main thread.
|
||||
Live entity, field, and receipt polling likewise runs as one non-overlapping
|
||||
background operation. Unity's main thread only applies completed typed
|
||||
documents, keeping Play, Pause, inspection, and rendering responsive while a
|
||||
provider is slow or temporarily unreachable.
|
||||
|
||||
The package never imports provider product assemblies.
|
||||
|
||||
|
|
@ -176,13 +149,3 @@ authority.
|
|||
Native volume programs may advertise a `cameraToWorld` matrix port when their
|
||||
procedural passes require an explicit camera transform instead of relying on
|
||||
render-pipeline globals.
|
||||
|
||||
Command finality is presentation-causal. When a terminal receipt names a
|
||||
`sourceVersion` newer than the mounted surface, EveUnity keeps the command and
|
||||
its exact receipt lease alive, makes the obsolete surface read-only, and emits
|
||||
the terminal receipt only after that surface generation and its qualified asset
|
||||
catalog have committed together. Asset preparation failure therefore cannot
|
||||
leave an accepted Verse switch paired with actionable controls from the prior
|
||||
Verse. Embedded surfaces may recompose the visible tree, but their versions are
|
||||
derived rendering inputs and cannot satisfy or clear the advertised base
|
||||
surface's finality barrier.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -3,7 +3,6 @@ using System.IO;
|
|||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Stopwatch = System.Diagnostics.Stopwatch;
|
||||
using GameCult.Eve.Surface;
|
||||
|
|
@ -40,18 +39,10 @@ namespace GameCult.Eve.UnityScene
|
|||
[FormerlySerializedAs("replicaPath")]
|
||||
[SerializeField] private string cacheDirectory = "";
|
||||
[SerializeField] private string runtimeId = "eve-unity";
|
||||
private CultMeshAuthorityTrustPolicy _authorityTrust = new CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
||||
private CultMeshAuthorityTrustPolicy _navigationAuthorityTrust = new CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
||||
|
||||
private EveUnityCultMeshLiveProviderTransport? _transport;
|
||||
private EveUnitySceneLiveProviderBridge? _bridge;
|
||||
private PreviousProvider? _previousProvider;
|
||||
private bool _navigationRouteCommitted;
|
||||
private int _connectionLeaseCount;
|
||||
private Task? _preparation;
|
||||
private CancellationTokenSource? _preparationLifetime;
|
||||
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
|
||||
private readonly ConcurrentQueue<EveFieldsSplatsDocument> _pendingFields = new ConcurrentQueue<EveFieldsSplatsDocument>();
|
||||
|
||||
|
|
@ -94,9 +85,7 @@ namespace GameCult.Eve.UnityScene
|
|||
string surfaceId = "",
|
||||
string verseId = "",
|
||||
string requiredSurfaceKind = "interactive-world",
|
||||
string clientRuntimeId = "eve-unity",
|
||||
CultMeshAuthorityTrustPolicy? authorityTrust = null,
|
||||
CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null)
|
||||
string clientRuntimeId = "eve-unity")
|
||||
{
|
||||
if (_bridge != null || _preparation != null)
|
||||
throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration.");
|
||||
|
|
@ -108,17 +97,11 @@ namespace GameCult.Eve.UnityScene
|
|||
verseFilter = verseId ?? "";
|
||||
surfaceKind = string.IsNullOrWhiteSpace(requiredSurfaceKind) ? "interactive-world" : requiredSurfaceKind;
|
||||
runtimeId = string.IsNullOrWhiteSpace(clientRuntimeId) ? "eve-unity" : clientRuntimeId;
|
||||
_authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
||||
_navigationAuthorityTrust = navigationAuthorityTrust ?? _authorityTrust;
|
||||
}
|
||||
|
||||
public void Connect()
|
||||
{
|
||||
// During navigation the candidate presentation connects before the route
|
||||
// becomes canonical, so every new lease must connect its selected bridge.
|
||||
Bridge.Connect();
|
||||
checked { _connectionLeaseCount++; }
|
||||
}
|
||||
|
||||
public Task PrepareAsync()
|
||||
|
|
@ -126,21 +109,13 @@ namespace GameCult.Eve.UnityScene
|
|||
if (_bridge != null)
|
||||
return Task.CompletedTask;
|
||||
if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted)
|
||||
{
|
||||
_preparationLifetime?.Dispose();
|
||||
_preparationLifetime = new CancellationTokenSource();
|
||||
_preparation = PrepareTransportAsync(_preparationLifetime.Token);
|
||||
}
|
||||
_preparation = PrepareTransportAsync();
|
||||
return _preparation;
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
if (_connectionLeaseCount <= 0)
|
||||
return;
|
||||
_connectionLeaseCount--;
|
||||
if (_connectionLeaseCount == 0)
|
||||
_bridge?.Disconnect();
|
||||
_bridge?.Disconnect();
|
||||
}
|
||||
|
||||
public async Task NavigateAsync(EveUnitySceneNavigationTarget target)
|
||||
|
|
@ -151,107 +126,29 @@ namespace GameCult.Eve.UnityScene
|
|||
if (string.IsNullOrWhiteSpace(target.SurfaceId))
|
||||
throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target));
|
||||
|
||||
var endpoints = (target.RendezvousEndpoints ?? Array.Empty<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;
|
||||
ReleaseTransport();
|
||||
var navigationEndpoint = target.RendezvousEndpoints
|
||||
.FirstOrDefault(endpoint => !string.IsNullOrWhiteSpace(endpoint));
|
||||
if (!string.IsNullOrWhiteSpace(navigationEndpoint))
|
||||
rendezvousEndpoint = navigationEndpoint;
|
||||
verseFilter = target.VerseId;
|
||||
providerFilter = target.ProviderId;
|
||||
surfaceFilter = target.SurfaceId;
|
||||
surfaceKind = requiredSurfaceKind;
|
||||
_authorityTrust = _navigationAuthorityTrust;
|
||||
_preparation = Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void CommitNavigation()
|
||||
{
|
||||
var previous = _previousProvider;
|
||||
if (previous == null) return;
|
||||
if (_navigationRouteCommitted) return;
|
||||
if (previous.Bridge != null)
|
||||
previous.Bridge.ReceiptAvailable -= ForwardReceipt;
|
||||
AttachPrepared(_transport, _bridge);
|
||||
_navigationRouteCommitted = true;
|
||||
}
|
||||
|
||||
public void FinalizeNavigation()
|
||||
{
|
||||
var previous = _previousProvider;
|
||||
if (previous == null) return;
|
||||
_previousProvider = null;
|
||||
_navigationRouteCommitted = false;
|
||||
SafeDispose(previous.Bridge);
|
||||
SafeDispose(previous.Transport);
|
||||
}
|
||||
|
||||
public void RollbackNavigation()
|
||||
{
|
||||
var previous = _previousProvider;
|
||||
if (previous == null) return;
|
||||
if (previous.Bridge != null)
|
||||
previous.Bridge.ReceiptAvailable -= ForwardReceipt;
|
||||
DetachPrepared(_transport, _bridge);
|
||||
var rejectedBridge = _bridge;
|
||||
var rejectedTransport = _transport;
|
||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||
while (_pendingFields.TryDequeue(out _)) { }
|
||||
|
||||
Selection = previous.Selection;
|
||||
_transport = previous.Transport;
|
||||
_bridge = previous.Bridge;
|
||||
rendezvousEndpoint = previous.RendezvousEndpoint;
|
||||
verseFilter = previous.VerseFilter;
|
||||
providerFilter = previous.ProviderFilter;
|
||||
surfaceFilter = previous.SurfaceFilter;
|
||||
surfaceKind = previous.SurfaceKind;
|
||||
_authorityTrust = previous.AuthorityTrust;
|
||||
AttachPrepared(_transport, _bridge);
|
||||
_previousProvider = null;
|
||||
_navigationRouteCommitted = false;
|
||||
_preparation = Task.CompletedTask;
|
||||
SafeDispose(rejectedBridge);
|
||||
SafeDispose(rejectedTransport);
|
||||
surfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) ? "interactive-world" : target.SurfaceKind;
|
||||
await PrepareAsync();
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
var activeBridge = _navigationRouteCommitted
|
||||
? Bridge
|
||||
: _previousProvider?.Bridge ?? Bridge;
|
||||
if (activeBridge.IsConnected)
|
||||
activeBridge.Refresh();
|
||||
if (Bridge.IsConnected)
|
||||
Bridge.Refresh();
|
||||
else
|
||||
activeBridge.Connect();
|
||||
Bridge.Connect();
|
||||
}
|
||||
|
||||
public void Submit(EveSurfaceCommandRequest request)
|
||||
{
|
||||
// Navigation preparation is not a command-routing commit. The mounted
|
||||
// presentation continues to target its old provider until the visual
|
||||
// candidate has been lowered and accepted by the bootstrap.
|
||||
(_navigationRouteCommitted ? Bridge : _previousProvider?.Bridge ?? Bridge).Submit(request);
|
||||
Bridge.Submit(request);
|
||||
}
|
||||
|
||||
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
|
||||
|
|
@ -287,7 +184,6 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
private void ReleaseTransport()
|
||||
{
|
||||
_preparationLifetime?.Cancel();
|
||||
if (_bridge != null)
|
||||
{
|
||||
_bridge.DocumentAvailable -= ForwardDocument;
|
||||
|
|
@ -295,24 +191,16 @@ namespace GameCult.Eve.UnityScene
|
|||
}
|
||||
_bridge?.Dispose();
|
||||
_transport?.Dispose();
|
||||
_previousProvider?.Bridge?.Dispose();
|
||||
_previousProvider?.Transport?.Dispose();
|
||||
_previousProvider = null;
|
||||
_navigationRouteCommitted = false;
|
||||
_connectionLeaseCount = 0;
|
||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||
while (_pendingFields.TryDequeue(out _)) { }
|
||||
_bridge = null;
|
||||
_transport = null;
|
||||
_preparation = null;
|
||||
_preparationLifetime?.Dispose();
|
||||
_preparationLifetime = null;
|
||||
Selection = null;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_previousProvider?.Transport?.PumpLiveEvents();
|
||||
_transport?.PumpLiveEvents();
|
||||
EveFieldsSplatsDocument? latestFields = null;
|
||||
while (_pendingFields.TryDequeue(out var fields))
|
||||
|
|
@ -340,7 +228,7 @@ namespace GameCult.Eve.UnityScene
|
|||
}
|
||||
}
|
||||
|
||||
private async Task PrepareTransportAsync(CancellationToken cancellationToken)
|
||||
private async Task PrepareTransportAsync()
|
||||
{
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
if (_transport != null)
|
||||
|
|
@ -348,183 +236,38 @@ namespace GameCult.Eve.UnityScene
|
|||
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
|
||||
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
|
||||
|
||||
var prepared = await PrepareCandidateAsync(
|
||||
new[] { rendezvousEndpoint },
|
||||
providerFilter,
|
||||
surfaceFilter,
|
||||
surfaceKind,
|
||||
verseFilter,
|
||||
"",
|
||||
_authorityTrust,
|
||||
_navigationAuthorityTrust,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
|
||||
AdoptPrepared(prepared);
|
||||
}
|
||||
|
||||
private async Task<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,
|
||||
Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync(
|
||||
rendezvousEndpoint,
|
||||
verseFilter,
|
||||
providerFilter,
|
||||
surfaceFilter,
|
||||
surfaceKind,
|
||||
_authorityTrust);
|
||||
_navigationRouteCommitted = false;
|
||||
DetachPrepared(_transport, _bridge);
|
||||
if (_previousProvider.Bridge != null)
|
||||
_previousProvider.Bridge.ReceiptAvailable += ForwardReceipt;
|
||||
Selection = prepared.Selection;
|
||||
_transport = prepared.Transport;
|
||||
_bridge = prepared.Bridge;
|
||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||
while (_pendingFields.TryDequeue(out _)) { }
|
||||
verseFilter);
|
||||
TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
|
||||
elapsed.Restart();
|
||||
var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory)
|
||||
? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}")
|
||||
: cacheDirectory;
|
||||
_transport = new EveUnityCultMeshLiveProviderTransport(
|
||||
resolvedCachePath,
|
||||
Selection.RendezvousEndpoint,
|
||||
Selection.EndpointId,
|
||||
Selection.ProviderId,
|
||||
Selection.SurfaceId,
|
||||
runtimeId);
|
||||
_transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
|
||||
_transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields);
|
||||
_bridge = new EveUnitySceneLiveProviderBridge(_transport);
|
||||
_bridge.DocumentAvailable += ForwardDocument;
|
||||
_bridge.ReceiptAvailable += ForwardReceipt;
|
||||
TraceStartup($"transport-construction {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
|
||||
}
|
||||
|
||||
private void AttachPrepared(
|
||||
EveUnityCultMeshLiveProviderTransport? transport,
|
||||
EveUnitySceneLiveProviderBridge? bridge)
|
||||
{
|
||||
if (transport != null)
|
||||
{
|
||||
transport.EntityViewAvailable += QueueEntityView;
|
||||
transport.FieldsSplatsAvailable += QueueFields;
|
||||
}
|
||||
if (bridge != null)
|
||||
{
|
||||
bridge.DocumentAvailable += ForwardDocument;
|
||||
bridge.ReceiptAvailable += ForwardReceipt;
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachPrepared(
|
||||
EveUnityCultMeshLiveProviderTransport? transport,
|
||||
EveUnitySceneLiveProviderBridge? bridge)
|
||||
{
|
||||
if (transport != null)
|
||||
{
|
||||
transport.EntityViewAvailable -= QueueEntityView;
|
||||
transport.FieldsSplatsAvailable -= QueueFields;
|
||||
}
|
||||
if (bridge == null) return;
|
||||
bridge.DocumentAvailable -= ForwardDocument;
|
||||
bridge.ReceiptAvailable -= ForwardReceipt;
|
||||
}
|
||||
|
||||
private void QueueEntityView(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease) =>
|
||||
_pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
|
||||
|
||||
private void QueueFields(EveFieldsSplatsDocument fields) => _pendingFields.Enqueue(fields);
|
||||
|
||||
private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) =>
|
||||
DocumentAvailable?.Invoke(document);
|
||||
|
||||
private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) =>
|
||||
ReceiptAvailable?.Invoke(receipt);
|
||||
|
||||
private static void SafeDispose(IDisposable? disposable)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable?.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Debug.LogWarning($"Eve provider cleanup failed after navigation ownership changed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceStartup(string message)
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal))
|
||||
|
|
@ -549,57 +292,5 @@ namespace GameCult.Eve.UnityScene
|
|||
public EveEntitySoaViewDocument Document { get; }
|
||||
public ICultMeshBodyReadLease Lease { get; }
|
||||
}
|
||||
|
||||
private sealed class PreparedProvider
|
||||
{
|
||||
public PreparedProvider(
|
||||
EveUnityCultMeshProviderSelection selection,
|
||||
EveUnityCultMeshLiveProviderTransport transport,
|
||||
EveUnitySceneLiveProviderBridge bridge)
|
||||
{
|
||||
Selection = selection;
|
||||
Transport = transport;
|
||||
Bridge = bridge;
|
||||
}
|
||||
|
||||
public EveUnityCultMeshProviderSelection Selection { get; }
|
||||
public EveUnityCultMeshLiveProviderTransport Transport { get; }
|
||||
public EveUnitySceneLiveProviderBridge Bridge { get; }
|
||||
}
|
||||
|
||||
private sealed class PreviousProvider
|
||||
{
|
||||
public PreviousProvider(
|
||||
EveUnityCultMeshProviderSelection? selection,
|
||||
EveUnityCultMeshLiveProviderTransport? transport,
|
||||
EveUnitySceneLiveProviderBridge? bridge,
|
||||
string rendezvousEndpoint,
|
||||
string verseFilter,
|
||||
string providerFilter,
|
||||
string surfaceFilter,
|
||||
string surfaceKind,
|
||||
CultMeshAuthorityTrustPolicy authorityTrust)
|
||||
{
|
||||
Selection = selection;
|
||||
Transport = transport;
|
||||
Bridge = bridge;
|
||||
RendezvousEndpoint = rendezvousEndpoint;
|
||||
VerseFilter = verseFilter;
|
||||
ProviderFilter = providerFilter;
|
||||
SurfaceFilter = surfaceFilter;
|
||||
SurfaceKind = surfaceKind;
|
||||
AuthorityTrust = authorityTrust;
|
||||
}
|
||||
|
||||
public EveUnityCultMeshProviderSelection? Selection { get; }
|
||||
public EveUnityCultMeshLiveProviderTransport? Transport { get; }
|
||||
public EveUnitySceneLiveProviderBridge? Bridge { get; }
|
||||
public string RendezvousEndpoint { get; }
|
||||
public string VerseFilter { get; }
|
||||
public string ProviderFilter { get; }
|
||||
public string SurfaceFilter { get; }
|
||||
public string SurfaceKind { get; }
|
||||
public CultMeshAuthorityTrustPolicy AuthorityTrust { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Eve.Surface;
|
||||
using GameCult.Mesh;
|
||||
|
|
@ -15,23 +14,23 @@ namespace GameCult.Eve.UnityScene
|
|||
{
|
||||
public EveUnityCultMeshProviderSelection(
|
||||
string rendezvousEndpoint,
|
||||
string endpointId,
|
||||
string verseId,
|
||||
string authorityRuntimeId,
|
||||
string providerId,
|
||||
string surfaceId,
|
||||
string surfaceKind)
|
||||
{
|
||||
RendezvousEndpoint = rendezvousEndpoint ?? "";
|
||||
EndpointId = endpointId ?? "";
|
||||
VerseId = verseId ?? "";
|
||||
AuthorityRuntimeId = authorityRuntimeId ?? "";
|
||||
ProviderId = providerId ?? "";
|
||||
SurfaceId = surfaceId ?? "";
|
||||
SurfaceKind = surfaceKind ?? "";
|
||||
}
|
||||
|
||||
public string RendezvousEndpoint { get; }
|
||||
public string EndpointId { get; }
|
||||
public string VerseId { get; }
|
||||
public string AuthorityRuntimeId { get; }
|
||||
public string ProviderId { get; }
|
||||
public string SurfaceId { get; }
|
||||
public string SurfaceKind { get; }
|
||||
|
|
@ -39,22 +38,21 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
public sealed class EveUnityCultMeshProviderDiscovery
|
||||
{
|
||||
private readonly CultMeshAuthorityTrustPolicy _authorityTrust;
|
||||
|
||||
public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null)
|
||||
{
|
||||
_authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
||||
}
|
||||
public EveUnityCultMeshProviderSelection Discover(
|
||||
string rendezvousEndpoint,
|
||||
string providerId = "",
|
||||
string surfaceId = "",
|
||||
string surfaceKind = "interactive-world",
|
||||
string verseId = "") =>
|
||||
DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
public async Task<EveUnityCultMeshProviderSelection> DiscoverAsync(
|
||||
string rendezvousEndpoint,
|
||||
string providerId = "",
|
||||
string surfaceId = "",
|
||||
string surfaceKind = "interactive-world",
|
||||
string verseId = "",
|
||||
CancellationToken cancellationToken = default,
|
||||
string requiredAuthorityRuntimeId = "")
|
||||
string verseId = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
|
||||
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
|
||||
|
|
@ -62,14 +60,13 @@ namespace GameCult.Eve.UnityScene
|
|||
CultMeshVerseCatalogResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync(
|
||||
response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync(
|
||||
rendezvousEndpoint,
|
||||
new CultMeshVerseCatalogRequestMessage
|
||||
{
|
||||
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
|
||||
TransportVersion = "cultmesh.v0"
|
||||
});
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
@ -94,74 +91,53 @@ namespace GameCult.Eve.UnityScene
|
|||
var observed = new List<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()
|
||||
RendezvousEndpoints = new[] { rendezvousEndpoint }
|
||||
});
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds(
|
||||
candidate.AuthorityRuntimeIds,
|
||||
requiredAuthorityRuntimeId))
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId);
|
||||
using var advertisementsLease = await mesh
|
||||
.LeaseCollectionAsync<EveProviderAdvertisementDocument>(target, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false);
|
||||
observed.AddRange(advertisements.Select(document =>
|
||||
$"{target}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]"));
|
||||
var advertisement = advertisements
|
||||
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
|
||||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
|
||||
.Select(document => new
|
||||
{
|
||||
Document = document,
|
||||
Surface = document.Surfaces.FirstOrDefault(surface =>
|
||||
(string.IsNullOrWhiteSpace(surfaceId) ||
|
||||
string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) &&
|
||||
(string.IsNullOrWhiteSpace(surfaceKind) ||
|
||||
string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal)))
|
||||
})
|
||||
.FirstOrDefault(match => match.Surface != null);
|
||||
if (advertisement?.Surface == null)
|
||||
continue;
|
||||
using var advertisementsLease = await mesh
|
||||
.LeaseCollectionAsync<EveProviderAdvertisementDocument>(candidate.VerseId)
|
||||
.ConfigureAwait(false);
|
||||
var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false);
|
||||
observed.AddRange(advertisements.Select(document =>
|
||||
$"{candidate.VerseId}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]"));
|
||||
var advertisement = advertisements
|
||||
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
|
||||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
|
||||
.Select(document => new
|
||||
{
|
||||
Document = document,
|
||||
Surface = document.Surfaces.FirstOrDefault(surface =>
|
||||
(string.IsNullOrWhiteSpace(surfaceId) ||
|
||||
string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) &&
|
||||
(string.IsNullOrWhiteSpace(surfaceKind) ||
|
||||
string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal)))
|
||||
})
|
||||
.FirstOrDefault(match => match.Surface != null);
|
||||
if (advertisement?.Surface == null)
|
||||
continue;
|
||||
|
||||
return new EveUnityCultMeshProviderSelection(
|
||||
rendezvousEndpoint,
|
||||
candidate.VerseId,
|
||||
authorityRuntimeId,
|
||||
advertisement.Document.ProviderId,
|
||||
advertisement.Surface.SurfaceId,
|
||||
advertisement.Surface.SurfaceKind);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failures.Add($"{candidate.VerseId}/{authorityRuntimeId}: {error.Message}");
|
||||
}
|
||||
return new EveUnityCultMeshProviderSelection(
|
||||
rendezvousEndpoint,
|
||||
candidate.VerseId,
|
||||
candidate.VerseId,
|
||||
advertisement.Document.ProviderId,
|
||||
advertisement.Surface.SurfaceId,
|
||||
advertisement.Surface.SurfaceKind);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failures.Add($"{candidate.VerseId}: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'";
|
||||
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'";
|
||||
var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}";
|
||||
var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}";
|
||||
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}");
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> EligibleAuthorityRuntimeIds(
|
||||
IEnumerable<string>? authorityRuntimeIds,
|
||||
string requiredAuthorityRuntimeId) =>
|
||||
(authorityRuntimeIds ?? Array.Empty<string>())
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Where(id => string.IsNullOrWhiteSpace(requiredAuthorityRuntimeId) ||
|
||||
string.Equals(id, requiredAuthorityRuntimeId, StringComparison.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@
|
|||
"GameCult.Caching.MessagePack.dll",
|
||||
"GameCult.Mesh.dll",
|
||||
"GameCult.Mesh.Quic.Native.dll",
|
||||
"GameCult.Networking.dll",
|
||||
"GameCult.Networking.WebSockets.dll"
|
||||
"GameCult.Networking.dll"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
|
|
|
|||
|
|
@ -30,13 +30,8 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
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)
|
||||
{
|
||||
|
|
@ -49,12 +44,7 @@ namespace GameCult.Eve.UnityScene
|
|||
BindNavigation();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
UnbindNavigation();
|
||||
_ownedPresentation?.Dispose();
|
||||
_ownedPresentation = null;
|
||||
}
|
||||
private void OnDestroy() => UnbindNavigation();
|
||||
|
||||
private void BindNavigation()
|
||||
{
|
||||
|
|
@ -87,67 +77,12 @@ namespace GameCult.Eve.UnityScene
|
|||
yield break;
|
||||
_navigationInProgress = true;
|
||||
var navigation = navigable.NavigateAsync(target);
|
||||
StagedPresentation? staged = null;
|
||||
PresentationSwap? swap = null;
|
||||
Transform? previousRoot = null;
|
||||
var previousRootWasActive = false;
|
||||
while (!navigation.IsCompleted)
|
||||
yield return null;
|
||||
try
|
||||
{
|
||||
navigation.GetAwaiter().GetResult();
|
||||
staged = PrepareStagedPresentation();
|
||||
previousRoot = sceneRoot;
|
||||
previousRootWasActive = previousRoot == null || previousRoot.gameObject.activeSelf;
|
||||
if (previousRoot != null && !ReferenceEquals(previousRoot, transform) &&
|
||||
!ReferenceEquals(previousRoot, staged.SceneRoot))
|
||||
previousRoot.gameObject.SetActive(false);
|
||||
navigable.CommitNavigation();
|
||||
swap = ActivateStagedPresentation(staged, previousRootWasActive);
|
||||
navigable.FinalizeNavigation();
|
||||
swap.Complete();
|
||||
swap = null;
|
||||
staged = null;
|
||||
LastNavigationFailure = null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
var providerRestored = false;
|
||||
try
|
||||
{
|
||||
navigable.RollbackNavigation();
|
||||
providerRestored = true;
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
Debug.LogError($"Eve provider route rollback failed; the previous presentation will remain inactive. {rollbackError}");
|
||||
}
|
||||
if (providerRestored)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (swap != null)
|
||||
swap.Rollback();
|
||||
else if (previousRoot != null)
|
||||
previousRoot.gameObject.SetActive(previousRootWasActive);
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
Debug.LogError($"Eve previous presentation rollback failed after provider restoration. {rollbackError}");
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
staged?.Dispose();
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
Debug.LogWarning($"Eve rejected presentation cleanup failed. {cleanupError}");
|
||||
}
|
||||
var failure = new EveUnitySceneNavigationFailure(target, error.Message, DateTimeOffset.UtcNow);
|
||||
LastNavigationFailure = failure;
|
||||
NavigationFailed?.Invoke(failure);
|
||||
Debug.LogError($"Eve provider navigation failed; the current surface remains mounted. {error}");
|
||||
Mount();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -155,255 +90,6 @@ namespace GameCult.Eve.UnityScene
|
|||
}
|
||||
}
|
||||
|
||||
private StagedPresentation PrepareStagedPresentation()
|
||||
{
|
||||
var resolvedProvider = provider;
|
||||
var surfaceDocuments = ResolveBehaviour<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()
|
||||
{
|
||||
var resolvedHost = ResolveHost();
|
||||
|
|
@ -557,21 +243,4 @@ namespace GameCult.Eve.UnityScene
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EveUnitySceneNavigationFailure
|
||||
{
|
||||
public EveUnitySceneNavigationFailure(
|
||||
EveUnitySceneNavigationTarget target,
|
||||
string diagnostic,
|
||||
DateTimeOffset failedAtUtc)
|
||||
{
|
||||
Target = target;
|
||||
Diagnostic = diagnostic;
|
||||
FailedAtUtc = failedAtUtc;
|
||||
}
|
||||
|
||||
public EveUnitySceneNavigationTarget Target { get; }
|
||||
public string Diagnostic { get; }
|
||||
public DateTimeOffset FailedAtUtc { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,12 +238,6 @@ namespace GameCult.Eve.UnityScene
|
|||
public interface IEveUnityNavigableProvider
|
||||
{
|
||||
Task NavigateAsync(EveUnitySceneNavigationTarget target);
|
||||
|
||||
void CommitNavigation();
|
||||
|
||||
void FinalizeNavigation();
|
||||
|
||||
void RollbackNavigation();
|
||||
}
|
||||
|
||||
public sealed class EveUnitySceneNavigationTarget
|
||||
|
|
@ -253,11 +247,9 @@ namespace GameCult.Eve.UnityScene
|
|||
string providerId,
|
||||
string surfaceId,
|
||||
string surfaceKind,
|
||||
IReadOnlyList<string>? rendezvousEndpoints = null,
|
||||
string authorityRuntimeId = "")
|
||||
IReadOnlyList<string>? rendezvousEndpoints = null)
|
||||
{
|
||||
VerseId = verseId ?? "";
|
||||
AuthorityRuntimeId = authorityRuntimeId ?? "";
|
||||
ProviderId = providerId ?? "";
|
||||
SurfaceId = surfaceId ?? "";
|
||||
SurfaceKind = surfaceKind ?? "";
|
||||
|
|
@ -265,7 +257,6 @@ namespace GameCult.Eve.UnityScene
|
|||
}
|
||||
|
||||
public string VerseId { get; }
|
||||
public string AuthorityRuntimeId { get; }
|
||||
public string ProviderId { get; }
|
||||
public string SurfaceId { get; }
|
||||
public string SurfaceKind { get; }
|
||||
|
|
|
|||
|
|
@ -172,11 +172,7 @@ namespace GameCult.Eve.UnityScene
|
|||
worldRoot.GetProp("exposureAdaptation", "progressive"),
|
||||
ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f),
|
||||
ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f),
|
||||
worldRoot.GetProp("colorGradingSpace"),
|
||||
worldRoot.GetProp("assetProviderId"),
|
||||
worldRoot.GetProp("assetVerseId"),
|
||||
worldRoot.GetProp("assetAuthorityRuntimeId"),
|
||||
ParseStringList(worldRoot.GetProp("assetRendezvousEndpoints")));
|
||||
worldRoot.GetProp("colorGradingSpace"));
|
||||
}
|
||||
|
||||
private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) =>
|
||||
|
|
@ -274,7 +270,7 @@ namespace GameCult.Eve.UnityScene
|
|||
private static IReadOnlyList<string> ParseStringList(string value)
|
||||
{
|
||||
return (value ?? "")
|
||||
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Split(',')
|
||||
.Select(part => part.Trim())
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
|
|
@ -447,11 +443,7 @@ namespace GameCult.Eve.UnityScene
|
|||
string exposureAdaptation = "progressive",
|
||||
float exposureSpeedUp = 2f,
|
||||
float exposureSpeedDown = 1f,
|
||||
string colorGradingSpace = "",
|
||||
string assetProviderId = "",
|
||||
string assetVerseId = "",
|
||||
string assetAuthorityRuntimeId = "",
|
||||
IReadOnlyList<string>? assetRendezvousEndpoints = null)
|
||||
string colorGradingSpace = "")
|
||||
{
|
||||
WorldRootId = worldRootId ?? "";
|
||||
StatePointerId = statePointerId ?? "";
|
||||
|
|
@ -515,10 +507,6 @@ namespace GameCult.Eve.UnityScene
|
|||
ExposureSpeedUp = exposureSpeedUp;
|
||||
ExposureSpeedDown = exposureSpeedDown;
|
||||
ColorGradingSpace = colorGradingSpace ?? "";
|
||||
AssetProviderId = assetProviderId ?? "";
|
||||
AssetVerseId = assetVerseId ?? "";
|
||||
AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? "";
|
||||
AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
public string WorldRootId { get; }
|
||||
|
|
@ -537,14 +525,6 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
public string AssetManifest { get; }
|
||||
|
||||
public string AssetProviderId { get; }
|
||||
|
||||
public string AssetVerseId { get; }
|
||||
|
||||
public string AssetAuthorityRuntimeId { get; }
|
||||
|
||||
public IReadOnlyList<string> AssetRendezvousEndpoints { get; }
|
||||
|
||||
public string InputProfile { get; }
|
||||
|
||||
public string CameraRig { get; }
|
||||
|
|
|
|||
|
|
@ -15,20 +15,16 @@ namespace GameCult.Eve.UnityScene
|
|||
var document = host.GetComponent<UIDocument>();
|
||||
if (document != null) return document;
|
||||
|
||||
var panel = host.transform.parent?.GetComponentInParent<UIDocument>()?.panelSettings;
|
||||
if (panel == null)
|
||||
{
|
||||
var theme = Resources.Load<ThemeStyleSheet>(ThemeResource);
|
||||
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>();
|
||||
panel.name = "Eve playable-world HUD panel";
|
||||
panel.scaleMode = PanelScaleMode.ScaleWithScreenSize;
|
||||
panel.referenceResolution = new Vector2Int(1920, 1080);
|
||||
panel.match = 0.5f;
|
||||
panel.themeStyleSheet = theme;
|
||||
}
|
||||
var panel = ScriptableObject.CreateInstance<PanelSettings>();
|
||||
panel.name = "Eve playable-world HUD panel";
|
||||
panel.scaleMode = PanelScaleMode.ScaleWithScreenSize;
|
||||
panel.referenceResolution = new Vector2Int(1920, 1080);
|
||||
panel.match = 0.5f;
|
||||
panel.themeStyleSheet = theme;
|
||||
|
||||
document = host.AddComponent<UIDocument>();
|
||||
document.panelSettings = panel;
|
||||
|
|
|
|||
|
|
@ -2,15 +2,11 @@ using System;
|
|||
using System.Reflection;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Eve.Surface;
|
||||
using GameCult.Eve.UnityScene;
|
||||
using GameCult.Mesh;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
|
|
@ -18,324 +14,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
{
|
||||
public sealed class EveUnityCultMeshAssetLookupTests
|
||||
{
|
||||
[Test]
|
||||
public void ConnectCannotPerformRemoteWorkBeforeAsynchronousPreparation()
|
||||
{
|
||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||
"test-cache",
|
||||
"cultnet+tcp://127.0.0.1:1",
|
||||
"test.verse",
|
||||
"test.runtime",
|
||||
"test.provider",
|
||||
"test.surface");
|
||||
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
var failure = Assert.Throws<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()
|
||||
{
|
||||
|
|
@ -348,177 +26,11 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
"interactive-world");
|
||||
|
||||
Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075"));
|
||||
Assert.That(selection.EndpointId, Is.EqualTo("aetheria.public"));
|
||||
Assert.That(selection.VerseId, Is.EqualTo("aetheria.public"));
|
||||
Assert.That(selection.AuthorityRuntimeId, Is.EqualTo("aetheria.public"));
|
||||
Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NavigationAuthorityCannotBeReplacedByAlphabeticallyEarlierPeer()
|
||||
{
|
||||
var target = new EveUnitySceneNavigationTarget(
|
||||
"gamecult.aetheria",
|
||||
"aetheria.daemon",
|
||||
"aetheria.pilot",
|
||||
"interactive-world",
|
||||
new[] { "cultnet+tcp://odin.example:3075" },
|
||||
"z-owner");
|
||||
var eligible = EveUnityCultMeshProviderDiscovery.EligibleAuthorityRuntimeIds(
|
||||
new[] { "a-decoy", "z-owner" },
|
||||
target.AuthorityRuntimeId);
|
||||
|
||||
Assert.That(target.AuthorityRuntimeId, Is.EqualTo("z-owner"));
|
||||
CollectionAssert.AreEqual(new[] { "z-owner" }, eligible);
|
||||
}
|
||||
|
||||
private static EveSurfaceCommandRequest Request(string id, string command) => new EveSurfaceCommandRequest(
|
||||
"test.provider",
|
||||
"test.surface",
|
||||
CultMesh.OperationInvocation("test.command", idempotencyKey: id),
|
||||
CultMesh.OperationPayload(new Dictionary<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()
|
||||
{
|
||||
|
|
@ -646,16 +158,29 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem()
|
||||
public void PublishingCatalogRegistersBundlesWithoutMaterializingThem()
|
||||
{
|
||||
var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
|
||||
"CurrentBundlePlatform",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
|
||||
"ConfigureAssetCatalog",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
var publishMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
|
||||
"PublishAssetCatalog",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var bundlesField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
|
||||
"_assetBundles",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var selectionsField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
|
||||
"_assetSelections",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(platformMethod, Is.Not.Null);
|
||||
Assert.That(configureMethod, Is.Not.Null);
|
||||
Assert.That(publishMethod, Is.Not.Null);
|
||||
|
||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||
Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"),
|
||||
"cultnet+tcp://127.0.0.1:1",
|
||||
"provider",
|
||||
"provider",
|
||||
"surface");
|
||||
var variant = new EveAssetVariant(
|
||||
"unity-scene",
|
||||
(string)platformMethod!.Invoke(null, Array.Empty<object>())!,
|
||||
|
|
@ -670,229 +195,11 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
1,
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) });
|
||||
var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1);
|
||||
|
||||
configureMethod!.Invoke(null, new[] { candidate, catalog });
|
||||
publishMethod!.Invoke(transport, new object[] { catalog });
|
||||
|
||||
Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero);
|
||||
Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1));
|
||||
((IDisposable)candidate).Dispose();
|
||||
Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero);
|
||||
Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FailedAssetCandidatePreservesCommittedGeneration()
|
||||
{
|
||||
var transportType = typeof(EveUnityCultMeshLiveProviderTransport);
|
||||
var generationField = transportType.GetField(
|
||||
"_assetGeneration",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
var configureMethod = transportType.GetMethod(
|
||||
"ConfigureAssetCatalog",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
using var transport = new EveUnityCultMeshLiveProviderTransport(
|
||||
Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"),
|
||||
"cultnet+tcp://127.0.0.1:1",
|
||||
"verse-a",
|
||||
"authority-a",
|
||||
"provider-a",
|
||||
"surface-a");
|
||||
var initial = (IDisposable)generationField.GetValue(transport)!;
|
||||
var committed = CreateAssetGeneration(
|
||||
"verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 1);
|
||||
var prefab = new GameObject("committed-a");
|
||||
GenerationDictionary(committed, "Prefabs")["prefab.a"] = prefab;
|
||||
initial.Dispose();
|
||||
generationField.SetValue(transport, committed);
|
||||
|
||||
var invalidVariant = new EveAssetVariant(
|
||||
"browser",
|
||||
"web",
|
||||
"url",
|
||||
"https://example.invalid/a",
|
||||
"sha256:00",
|
||||
1,
|
||||
"prefab.a");
|
||||
var invalidCatalog = new EveAssetCatalogDocument(
|
||||
"provider-b",
|
||||
"catalog-b",
|
||||
2,
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) });
|
||||
var rejected = CreateAssetGeneration(
|
||||
"verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1);
|
||||
var error = Assert.Throws<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,7 +3,6 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Collections;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
|
|
@ -33,7 +32,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
"cultnet://127.0.0.1:3075",
|
||||
"aetheria",
|
||||
"aetheria",
|
||||
"aetheria",
|
||||
"aetheria.pilot");
|
||||
});
|
||||
}
|
||||
|
|
@ -69,7 +67,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
"cultnet://127.0.0.1:3075",
|
||||
"aetheria",
|
||||
"aetheria",
|
||||
"aetheria",
|
||||
"aetheria.pilot");
|
||||
var view = EntityLeaseDocument();
|
||||
var publication = BodyPublication(view, view.Sequence + 1);
|
||||
|
|
@ -105,7 +102,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
"cultnet://127.0.0.1:3075",
|
||||
"aetheria",
|
||||
"aetheria",
|
||||
"aetheria",
|
||||
"aetheria.pilot");
|
||||
var view = EntityLeaseDocument();
|
||||
var publication = BodyPublication(view, view.Sequence);
|
||||
|
|
@ -135,7 +131,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
"cultnet://127.0.0.1:3075",
|
||||
"provider",
|
||||
"provider",
|
||||
"provider",
|
||||
"provider.pilot");
|
||||
var enqueue = typeof(EveUnityCultMeshLiveProviderTransport)
|
||||
.GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
|
|
@ -166,7 +161,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
"cultnet://127.0.0.1:3075",
|
||||
"provider",
|
||||
"provider",
|
||||
"provider",
|
||||
"provider.pilot");
|
||||
var view = EntityLeaseDocument();
|
||||
view.Sequence = 0;
|
||||
|
|
@ -582,10 +576,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
Assert.That(projection.PlayableWorld.EntityBodyId, Is.EqualTo("eve:entity-soa:aetheria.daemon:pilot"));
|
||||
Assert.That(projection.PlayableWorld.ZoneRenderPointerId, Is.EqualTo("cultmesh://aetheria/world/zone-render"));
|
||||
Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest"));
|
||||
Assert.That(projection.PlayableWorld.AssetProviderId, Is.EqualTo("aetheria.assets"));
|
||||
Assert.That(projection.PlayableWorld.AssetVerseId, Is.EqualTo("aetheria.mods"));
|
||||
Assert.That(projection.PlayableWorld.AssetAuthorityRuntimeId, Is.EqualTo("mod-authority"));
|
||||
Assert.That(projection.PlayableWorld.AssetRendezvousEndpoints, Is.EqualTo(new[] { "wss://odin-a/cultmesh", "wss://odin-b/cultmesh" }));
|
||||
Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person"));
|
||||
Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1"));
|
||||
Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty);
|
||||
|
|
@ -1175,9 +1165,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
var bootstrap = hostObject.AddComponent<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",
|
||||
|
|
@ -1199,153 +1186,7 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
Assert.That(provider.LastNavigation, Is.Not.Null);
|
||||
Assert.That(provider.LastNavigation!.VerseId, Is.EqualTo("gamecult.aetheria"));
|
||||
Assert.That(provider.LastNavigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.example:3076" }));
|
||||
Assert.That(bootstrap.Host, Is.Not.SameAs(originalHost));
|
||||
Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1));
|
||||
Assert.That(provider.CommitNavigationCount, Is.EqualTo(1));
|
||||
Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(1));
|
||||
Assert.That(provider.RollbackNavigationCount, Is.Zero);
|
||||
Assert.That(provider.PreviousPresentationActiveDuringCommit, Is.False);
|
||||
|
||||
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
|
||||
"receipt:launch-again",
|
||||
"aetheria.hangar.launch",
|
||||
"launch",
|
||||
"accepted",
|
||||
"Aetheria",
|
||||
"commander-daemon",
|
||||
navigation: new EveUnitySceneNavigationTarget(
|
||||
"gamecult.aetheria",
|
||||
"aetheria.daemon",
|
||||
"aetheria.pilot",
|
||||
"interactive-world",
|
||||
new[] { "cultnet+tcp://odin.example:3076" })));
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
|
||||
Assert.That(provider.CommitNavigationCount, Is.EqualTo(2));
|
||||
Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(2));
|
||||
Assert.That(provider.ActiveConnectionLeases, Is.EqualTo(1),
|
||||
"disposing superseded presentations must leave the committed Verse connected");
|
||||
Assert.That(hostObject.transform.Cast<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);
|
||||
Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(2));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -2734,10 +2575,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render",
|
||||
["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1",
|
||||
["assetManifest"] = "cultmesh://aetheria/assets/manifest",
|
||||
["assetProviderId"] = "aetheria.assets",
|
||||
["assetVerseId"] = "aetheria.mods",
|
||||
["assetAuthorityRuntimeId"] = "mod-authority",
|
||||
["assetRendezvousEndpoints"] = "wss://odin-a/cultmesh;wss://odin-b/cultmesh",
|
||||
["inputProfile"] = "arpg-third-person",
|
||||
["cameraRig"] = cameraRig,
|
||||
["cameraLookAt"] = cameraLookAt,
|
||||
|
|
@ -3443,7 +3280,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
IEveUnitySceneCommandReceiptSource,
|
||||
IEveUnityProviderRefreshSource,
|
||||
IEveUnityInputCapabilitySource,
|
||||
IEveUnitySceneProviderSurfaceDocumentConnection,
|
||||
IEveUnityNavigableProvider
|
||||
{
|
||||
public List<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
|
||||
|
|
@ -3452,28 +3288,6 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
|
||||
public EveUnitySceneNavigationTarget? LastNavigation { get; private set; }
|
||||
|
||||
public int CommitNavigationCount { get; private set; }
|
||||
|
||||
public int FinalizeNavigationCount { get; private set; }
|
||||
|
||||
public int RollbackNavigationCount { get; private set; }
|
||||
|
||||
public bool FailNextNavigationMount { get; set; }
|
||||
|
||||
public bool FailNextNavigationFinalize { get; set; }
|
||||
|
||||
public int ActiveConnectionLeases { get; private set; }
|
||||
|
||||
public string ActiveRoute { get; private set; } = "original";
|
||||
|
||||
public Func<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 ManifestRef => CurrentDocument.ManifestRef;
|
||||
|
|
@ -3495,9 +3309,7 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
new EveInputCapabilityDocument();
|
||||
|
||||
EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument =>
|
||||
_failSurfaceRead
|
||||
? throw new InvalidOperationException("Deliberate candidate surface read failure.")
|
||||
: CurrentSurfaceDocument;
|
||||
CurrentSurfaceDocument;
|
||||
|
||||
public event Action<EveUnitySceneProviderSurfaceDocument>? DocumentAvailable;
|
||||
|
||||
|
|
@ -3541,47 +3353,8 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
public Task NavigateAsync(EveUnitySceneNavigationTarget target)
|
||||
{
|
||||
LastNavigation = target;
|
||||
_failSurfaceRead = FailNextNavigationMount;
|
||||
FailNextNavigationMount = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Connect()
|
||||
{
|
||||
ActiveConnectionLeases++;
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
if (ActiveConnectionLeases > 0)
|
||||
ActiveConnectionLeases--;
|
||||
}
|
||||
|
||||
public void CommitNavigation()
|
||||
{
|
||||
PreviousPresentationActiveDuringCommit = PreviousPresentationActive?.Invoke();
|
||||
CommitNavigationCount++;
|
||||
ActiveRoute = "candidate";
|
||||
_failSurfaceRead = false;
|
||||
}
|
||||
|
||||
public void FinalizeNavigation()
|
||||
{
|
||||
FinalizeNavigationCount++;
|
||||
if (FailNextNavigationFinalize)
|
||||
{
|
||||
FailNextNavigationFinalize = false;
|
||||
throw new InvalidOperationException("Deliberate provider finalization failure.");
|
||||
}
|
||||
}
|
||||
|
||||
public void RollbackNavigation()
|
||||
{
|
||||
RollbackNavigationCount++;
|
||||
ActiveRoute = "original";
|
||||
_failSurfaceRead = false;
|
||||
PreviousPresentationActiveDuringRollback = PreviousPresentationActive?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "org.gamecult.eve.unity-scene",
|
||||
"displayName": "Eve Unity Scene Lowering",
|
||||
"version": "0.3.134",
|
||||
"version": "0.3.105",
|
||||
"unity": "6000.0",
|
||||
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
|
||||
"dependencies": {
|
||||
"org.gamecult.cultlib": "1.0.56",
|
||||
"org.gamecult.cultlib": "1.0.46",
|
||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||
"org.gamecult.eve.surface": "0.3.6",
|
||||
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
|
||||
"org.gamecult.eve.surface": "0.3.1",
|
||||
"org.gamecult.eve.unity-uitoolkit": "0.1.3",
|
||||
"com.unity.mathematics": "1.3.2",
|
||||
"com.unity.render-pipelines.universal": "17.4.0",
|
||||
"com.unity.modules.particlesystem": "1.0.0"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
private Vector2 _inventoryDragStart;
|
||||
private bool _inventoryPointerMoved;
|
||||
private bool _suppressInventoryClick;
|
||||
private readonly List<VisualElement> _inventoryPreviewCells = new();
|
||||
|
||||
public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null)
|
||||
{
|
||||
|
|
@ -46,7 +45,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}");
|
||||
element.userData = component;
|
||||
ApplyGeneratedLayout(element, component);
|
||||
element.SetEnabled(IsEnabled(component));
|
||||
|
||||
if (IsHidden(component) || IsExternalProjectionRoot(component.Kind))
|
||||
return element;
|
||||
|
|
@ -132,8 +130,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
element.style.flexWrap = Wrap.Wrap;
|
||||
return element;
|
||||
}
|
||||
case "scroll":
|
||||
return new ScrollView(ScrollViewMode.Vertical);
|
||||
case "pane":
|
||||
case "modal":
|
||||
case "card":
|
||||
|
|
@ -277,7 +273,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
return;
|
||||
if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink))
|
||||
_inventoryDragSource = null;
|
||||
ClearInventoryPreview();
|
||||
});
|
||||
return grid;
|
||||
}
|
||||
|
|
@ -296,7 +291,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
_inventoryDragSource = component;
|
||||
_inventoryDragStart = evt.position;
|
||||
_inventoryPointerMoved = false;
|
||||
ClearInventoryPreview();
|
||||
item.CapturePointer(evt.pointerId);
|
||||
evt.StopPropagation();
|
||||
});
|
||||
|
|
@ -306,15 +300,7 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
return;
|
||||
var pointerPosition = new Vector2(evt.position.x, evt.position.y);
|
||||
if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f)
|
||||
{
|
||||
_inventoryPointerMoved = true;
|
||||
var picked = item.panel?.Pick(evt.position);
|
||||
var targetElement = InventoryGridAncestor(picked);
|
||||
if (targetElement?.userData is EveSurfaceComponent target)
|
||||
ShowInventoryPreview(component, target, targetElement, evt.position);
|
||||
else
|
||||
ClearInventoryPreview();
|
||||
}
|
||||
});
|
||||
item.RegisterCallback<PointerUpEvent>(evt =>
|
||||
{
|
||||
|
|
@ -329,7 +315,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink);
|
||||
_inventoryDragSource = null;
|
||||
_suppressInventoryClick = true;
|
||||
ClearInventoryPreview();
|
||||
}
|
||||
evt.StopPropagation();
|
||||
});
|
||||
|
|
@ -421,50 +406,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
return true;
|
||||
}
|
||||
|
||||
private void ShowInventoryPreview(
|
||||
EveSurfaceComponent source,
|
||||
EveSurfaceComponent target,
|
||||
VisualElement targetElement,
|
||||
Vector2 panelPosition)
|
||||
{
|
||||
ClearInventoryPreview();
|
||||
var local = targetElement.WorldToLocal(panelPosition);
|
||||
var cellSize = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f));
|
||||
var gap = Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f));
|
||||
var pitch = cellSize + gap;
|
||||
var x = Math.Max(0, (int)Math.Floor(local.x / Math.Max(1f, pitch)));
|
||||
var y = Math.Max(0, (int)Math.Floor(local.y / Math.Max(1f, pitch)));
|
||||
if (!EveInventoryInteraction.TryCreatePlacementPreview(source, target, x, y, out var preview) ||
|
||||
preview == null)
|
||||
return;
|
||||
|
||||
foreach (var cell in preview.Cells)
|
||||
{
|
||||
var element = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
element.AddToClassList("eve-inventory-placement-preview");
|
||||
element.AddToClassList(preview.IsValid
|
||||
? "eve-inventory-placement-valid"
|
||||
: "eve-inventory-placement-invalid");
|
||||
element.style.position = Position.Absolute;
|
||||
element.style.left = cell.X * pitch;
|
||||
element.style.top = cell.Y * pitch;
|
||||
element.style.width = cellSize;
|
||||
element.style.height = cellSize;
|
||||
element.style.backgroundColor = preview.IsValid
|
||||
? new Color(0.2f, 0.9f, 0.65f, 0.34f)
|
||||
: new Color(1f, 0.25f, 0.2f, 0.38f);
|
||||
targetElement.Add(element);
|
||||
_inventoryPreviewCells.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearInventoryPreview()
|
||||
{
|
||||
foreach (var cell in _inventoryPreviewCells)
|
||||
cell.RemoveFromHierarchy();
|
||||
_inventoryPreviewCells.Clear();
|
||||
}
|
||||
|
||||
private static int ParseInt(string value, int fallback) =>
|
||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
|
||||
|
||||
|
|
@ -538,26 +479,18 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
})
|
||||
.Where(option => !string.IsNullOrWhiteSpace(option.Value))
|
||||
.ToArray();
|
||||
var duplicateLabels = options
|
||||
.GroupBy(option => option.Label, StringComparer.Ordinal)
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(group => group.Key)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var labels = options
|
||||
.Select(option => duplicateLabels.Contains(option.Label)
|
||||
? $"{option.Label} [{option.Value}]"
|
||||
: option.Label)
|
||||
.ToList();
|
||||
var labels = options.Select(option => option.Label).ToList();
|
||||
var selectedValue = component.GetProp("value");
|
||||
var selectedIndex = Array.FindIndex(options, option =>
|
||||
string.Equals(option.Value, selectedValue, StringComparison.Ordinal));
|
||||
var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex));
|
||||
field.SetEnabled(!ParseBool(component.GetProp("disabled")));
|
||||
field.RegisterValueChangedCallback(change =>
|
||||
{
|
||||
var index = field.index;
|
||||
if (index < 0 || index >= options.Length)
|
||||
var option = options.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Label, change.newValue, StringComparison.Ordinal));
|
||||
if (option == null)
|
||||
return;
|
||||
var option = options[index];
|
||||
var payload = new Dictionary<string, string>(component.Props, StringComparer.Ordinal)
|
||||
{
|
||||
["value"] = option.Value
|
||||
|
|
@ -667,12 +600,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
element.style.alignItems = align;
|
||||
if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify))
|
||||
element.style.justifyContent = justify;
|
||||
if (TryGet(layout, "flexGrow", out var flexGrow))
|
||||
element.style.flexGrow = ParseFloat(flexGrow);
|
||||
if (TryGet(layout, "flexShrink", out var flexShrink))
|
||||
element.style.flexShrink = ParseFloat(flexShrink);
|
||||
if (TryGet(layout, "flexBasis", out var flexBasis))
|
||||
element.style.flexBasis = ParseLength(flexBasis);
|
||||
if (TryGet(layout, "width", out var width))
|
||||
element.style.width = ParseLength(width);
|
||||
if (TryGet(layout, "minWidth", out var minWidth))
|
||||
|
|
@ -749,14 +676,6 @@ namespace GameCult.Eve.UnityUIToolkit
|
|||
return false;
|
||||
}
|
||||
|
||||
private static bool IsEnabled(EveSurfaceComponent component)
|
||||
{
|
||||
if (ParseBool(component.GetProp("disabled")))
|
||||
return false;
|
||||
var enabled = component.GetProp("enabled");
|
||||
return string.IsNullOrWhiteSpace(enabled) || ParseBool(enabled);
|
||||
}
|
||||
|
||||
private static StyleLength ParseLength(string value)
|
||||
{
|
||||
value = FirstToken(value);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GameCult.Eve.Surface;
|
||||
using GameCult.Mesh;
|
||||
using NUnit.Framework;
|
||||
|
|
@ -52,69 +51,6 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
|
|||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SelectKeepsStableValuesWhenVerseLabelsCollide()
|
||||
{
|
||||
EveSurfaceCommandRequest? emitted = null;
|
||||
var select = Component(
|
||||
"verse",
|
||||
"control.select",
|
||||
new Dictionary<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]
|
||||
public void DefaultOptionsExposeSaiNornAndTeXProjectionAdapters()
|
||||
{
|
||||
|
|
@ -308,112 +244,18 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
|
|||
["targetKind"] = "equipment",
|
||||
["targetEntityKey"] = "zone.0.entity.1",
|
||||
["targetIndex"] = "-1",
|
||||
["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem",
|
||||
["payload.shipId"] = "hangar.ship.1",
|
||||
["payload.expectedHangarRevision"] = "42"
|
||||
["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem"
|
||||
});
|
||||
|
||||
Assert.That(EveInventoryInteraction.TryCreateDropRequest(
|
||||
Document(target), source, target, 7, 9, "unity-test", out var request), Is.True);
|
||||
Assert.That(request, Is.Not.Null);
|
||||
Assert.That(request!.Command, Is.EqualTo("aetheria.daemon.commands.EquipItem"));
|
||||
Assert.That(request.CommandId, Is.Not.Empty);
|
||||
Assert.That(request.Payload.GetString("originEntityKey"), Is.EqualTo("zone.0.entity.1"));
|
||||
Assert.That(request.Payload.GetString("originCargoIndex"), Is.EqualTo("2"));
|
||||
Assert.That(request.Payload.GetString("destinationX"), Is.EqualTo("7"));
|
||||
Assert.That(request.Payload.GetString("destinationY"), Is.EqualTo("9"));
|
||||
Assert.That(request.Payload.GetString("hasDestinationPosition"), Is.EqualTo("true"));
|
||||
Assert.That(request.Payload.GetString("shipId"), Is.EqualTo("hangar.ship.1"));
|
||||
Assert.That(request.Payload.GetString("expectedHangarRevision"), Is.EqualTo("42"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InventoryPlacementPreviewUsesIrregularShapeAndCurrentOccupancy()
|
||||
{
|
||||
var moving = Component("moving", EveInventoryInteraction.ItemKind, new Dictionary<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]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "org.gamecult.eve.unity-uitoolkit",
|
||||
"displayName": "Eve UI Toolkit Lowering",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.3",
|
||||
"unity": "2021.3",
|
||||
"description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.",
|
||||
"dependencies": {
|
||||
"org.gamecult.eve.surface": "0.3.6"
|
||||
"org.gamecult.eve.surface": "0.3.0"
|
||||
},
|
||||
"keywords": [
|
||||
"Eve",
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ param(
|
|||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$expectedEveUnityCommit = "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
||||
$expectedEveUnityCommit = "ddd2c5871f935978a0640d43cb58e18579523125"
|
||||
$expectedEveFieldsCommit = "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
|
||||
$expectedEveUnityUiToolkitCommit = "d1663b36d12b60ff5c7249135eff48edf6227ca9"
|
||||
$expectedCultLibCommit = "334e60f1928b4212a29dd8b0d19b2c099fe6365e"
|
||||
$expectedEveUnityUiToolkitCommit = "44390549775ec535ee31fc60a5bc594f372f4147"
|
||||
$expectedCultLibCommit = "f67f5122ed1bd11da016e7b820ed60145ccd0299"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$projectRoot = $ClientProject
|
||||
$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory }
|
||||
|
|
|
|||
|
|
@ -1,87 +1,50 @@
|
|||
param(
|
||||
[string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe",
|
||||
[string] $CultLibRoot = "",
|
||||
[string] $EveRoot = "",
|
||||
[string] $EvePluginsRoot = "",
|
||||
[string] $NuGetConfig = "",
|
||||
[switch] $SkipCultLibBuild,
|
||||
[string] $CultLibRoot = "E:\Projects\CultLib",
|
||||
[string] $ProjectRoot = "TestProject",
|
||||
[string] $OutputRoot = "artifacts\package-tests"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$workspaceRoot = Split-Path -Parent $repoRoot
|
||||
if ([string]::IsNullOrWhiteSpace($CultLibRoot)) { $CultLibRoot = Join-Path $workspaceRoot "CultLib" }
|
||||
if ([string]::IsNullOrWhiteSpace($EveRoot)) { $EveRoot = Join-Path $workspaceRoot "Eve" }
|
||||
if ([string]::IsNullOrWhiteSpace($EvePluginsRoot)) { $EvePluginsRoot = Join-Path $workspaceRoot "EvePlugins" }
|
||||
$projectPath = if ([IO.Path]::IsPathRooted($ProjectRoot)) { $ProjectRoot } else { Join-Path $repoRoot $ProjectRoot }
|
||||
$output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot }
|
||||
$cultLibBuilder = Join-Path $CultLibRoot "scripts\build-unity-package.ps1"
|
||||
$obsoleteSurfacePackage = Join-Path $repoRoot "packages\org.gamecult.eve.surface"
|
||||
$obsoleteSurfaceSource = @("package.json", "Runtime", "Editor") |
|
||||
ForEach-Object { Join-Path $obsoleteSurfacePackage $_ } |
|
||||
Where-Object { Test-Path -LiteralPath $_ }
|
||||
if ($obsoleteSurfaceSource.Count -gt 0) {
|
||||
$testManifestPath = Join-Path $projectPath "Packages\manifest.json"
|
||||
if (Test-Path -LiteralPath $obsoleteSurfacePackage) {
|
||||
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")
|
||||
)) {
|
||||
$testManifest = Get-Content -LiteralPath $testManifestPath -Raw | ConvertFrom-Json
|
||||
$surfaceDependency = [string]$testManifest.dependencies."org.gamecult.eve.surface"
|
||||
if ($surfaceDependency -notmatch '(?i)Eve/packages/org\.gamecult\.eve\.surface$') {
|
||||
throw "EveUnity test consumer must resolve org.gamecult.eve.surface from Eve; found '$surfaceDependency'."
|
||||
}
|
||||
foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) {
|
||||
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" }
|
||||
}
|
||||
|
||||
if (-not $SkipCultLibBuild) {
|
||||
$cultLibBuildArguments = @("-ExecutionPolicy", "Bypass", "-File", $cultLibBuilder)
|
||||
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
|
||||
$cultLibBuildArguments += @("-NuGetConfig", [IO.Path]::GetFullPath($NuGetConfig))
|
||||
}
|
||||
& powershell @cultLibBuildArguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
powershell -ExecutionPolicy Bypass -File $cultLibBuilder
|
||||
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
||||
|
||||
$builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib"
|
||||
$stagedDependenciesRoot = Join-Path $repoRoot "artifacts\test-dependencies"
|
||||
$stagedCultLibPackage = Join-Path $stagedDependenciesRoot "org.gamecult.cultlib"
|
||||
if (-not (Test-Path -LiteralPath $builtCultLibPackage)) {
|
||||
throw "CultLib Unity package build did not produce: $builtCultLibPackage"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $stagedDependenciesRoot | Out-Null
|
||||
if (Test-Path -LiteralPath $stagedCultLibPackage) {
|
||||
[IO.Directory]::Delete($stagedCultLibPackage, $true)
|
||||
}
|
||||
Copy-Item -LiteralPath $builtCultLibPackage -Destination $stagedCultLibPackage -Recurse
|
||||
|
||||
$runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss")
|
||||
$consumerPath = Join-Path $runRoot "consumer"
|
||||
$consumerPackagesPath = Join-Path $consumerPath "Packages"
|
||||
$stagedDependenciesRoot = Join-Path $runRoot "dependencies"
|
||||
New-Item -ItemType Directory -Force -Path $consumerPath, $consumerPackagesPath, $stagedDependenciesRoot | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $projectPath "Assets") -Destination (Join-Path $consumerPath "Assets") -Recurse
|
||||
Copy-Item -LiteralPath (Join-Path $projectPath "ProjectSettings") -Destination (Join-Path $consumerPath "ProjectSettings") -Recurse
|
||||
|
||||
$dependencySources = [ordered]@{
|
||||
"org.gamecult.cultlib" = $builtCultLibPackage
|
||||
"org.gamecult.eve.plugin-fields" = Join-Path $EvePluginsRoot "plugins\eve-plugin-fields\unity\org.gamecult.eve.plugin-fields"
|
||||
"org.gamecult.eve.surface" = Join-Path $EveRoot "packages\org.gamecult.eve.surface"
|
||||
"org.gamecult.eve.unity-scene" = Join-Path $repoRoot "packages\org.gamecult.eve.unity-scene"
|
||||
"org.gamecult.eve.unity-uitoolkit" = Join-Path $repoRoot "packages\org.gamecult.eve.unity-uitoolkit"
|
||||
}
|
||||
foreach ($entry in $dependencySources.GetEnumerator()) {
|
||||
Copy-Item -LiteralPath $entry.Value -Destination (Join-Path $stagedDependenciesRoot $entry.Key) -Recurse
|
||||
}
|
||||
|
||||
$manifest = Get-Content -LiteralPath (Join-Path $projectPath "Packages\manifest.json") -Raw | ConvertFrom-Json
|
||||
foreach ($packageId in $dependencySources.Keys) {
|
||||
$manifest.dependencies.$packageId = "file:../../dependencies/$packageId"
|
||||
}
|
||||
$manifestJson = $manifest | ConvertTo-Json -Depth 20
|
||||
[IO.File]::WriteAllText(
|
||||
(Join-Path $consumerPackagesPath "manifest.json"),
|
||||
$manifestJson,
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
|
||||
$resultsPath = Join-Path $runRoot "unity-editmode-results.xml"
|
||||
$logPath = Join-Path $runRoot "unity-editmode.log"
|
||||
$arguments = @(
|
||||
"-batchmode", "-projectPath", $consumerPath,
|
||||
"-batchmode", "-projectPath", $projectPath,
|
||||
"-runTests", "-testPlatform", "EditMode",
|
||||
"-assemblyNames", "GameCult.Eve.UnityScene.Tests",
|
||||
"-testResults", $resultsPath, "-logFile", $logPath
|
||||
|
|
@ -109,5 +72,5 @@ if ($null -eq $run -or [int]$run.total -eq 0 -or [int]$run.failed -gt 0) {
|
|||
throw "EveUnity package tests did not pass: $resultsPath"
|
||||
}
|
||||
Write-Host "EveUnity generic package tests: $($run.passed) passed, $($run.total) total"
|
||||
Write-Host "Isolated consumer: $consumerPath"
|
||||
Write-Host "Staged dependencies: $stagedDependenciesRoot"
|
||||
Write-Host "Consumer: $projectPath"
|
||||
Write-Host "CultLib package: $stagedCultLibPackage"
|
||||
|
|
|
|||
|
|
@ -5,16 +5,9 @@ param(
|
|||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$sourceProjectPath = Join-Path $repoRoot "ReleaseConsumerProject"
|
||||
$projectPath = "ReleaseConsumerProject"
|
||||
$results = if ([IO.Path]::IsPathRooted($ResultsPath)) { $ResultsPath } else { Join-Path $repoRoot $ResultsPath }
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $results) | Out-Null
|
||||
$runRoot = Join-Path (Split-Path -Parent $results) ("release-consumer-" + (Get-Date -Format "yyyyMMddTHHmmss"))
|
||||
$projectPath = Join-Path $runRoot "consumer"
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $projectPath "Packages") | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $sourceProjectPath "Assets") -Destination (Join-Path $projectPath "Assets") -Recurse
|
||||
Copy-Item -LiteralPath (Join-Path $sourceProjectPath "ProjectSettings") -Destination (Join-Path $projectPath "ProjectSettings") -Recurse
|
||||
Copy-Item -LiteralPath (Join-Path $sourceProjectPath "Packages\manifest.json") `
|
||||
-Destination (Join-Path $projectPath "Packages\manifest.json")
|
||||
$logPath = [IO.Path]::ChangeExtension($results, ".log")
|
||||
$arguments = @(
|
||||
"-batchmode", "-projectPath", $projectPath,
|
||||
|
|
@ -22,12 +15,7 @@ $arguments = @(
|
|||
"-testResults", $results, "-logFile", $logPath
|
||||
)
|
||||
|
||||
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden
|
||||
if (-not $process.WaitForExit([int][TimeSpan]::FromMinutes(10).TotalMilliseconds)) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
||||
throw "Released EveUnity package consumer tests timed out after 10 minutes (Unity PID $($process.Id))."
|
||||
}
|
||||
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -WorkingDirectory $repoRoot -Wait -PassThru -WindowStyle Hidden
|
||||
if ($process.ExitCode -ne 0) {
|
||||
if (Test-Path $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
|
||||
throw "Released EveUnity package consumer tests failed with exit code $($process.ExitCode)"
|
||||
|
|
@ -38,4 +26,3 @@ if (-not (Test-Path $results)) { throw "Unity did not produce release consumer t
|
|||
$result = $report.'test-run'.result
|
||||
if ($result -ne "Passed") { throw "Released EveUnity package consumer result was '$result'." }
|
||||
Write-Host "Released EveUnity package consumer passed: $results"
|
||||
Write-Host "Isolated git consumer: $projectPath"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
||||
& $cultLibBuilder
|
||||
powershell -ExecutionPolicy Bypass -File $cultLibBuilder
|
||||
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
||||
|
||||
$builtCultLibPackage = Join-Path $CultLibRoot "artifacts\unity\org.gamecult.cultlib"
|
||||
$stagedDependenciesRoot = Join-Path $repoRoot "artifacts\test-dependencies"
|
||||
$stagedCultLibPackage = Join-Path $stagedDependenciesRoot "org.gamecult.cultlib"
|
||||
if (-not (Test-Path -LiteralPath $builtCultLibPackage)) {
|
||||
throw "CultLib Unity package build did not produce: $builtCultLibPackage"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $stagedDependenciesRoot | Out-Null
|
||||
if (Test-Path -LiteralPath $stagedCultLibPackage) {
|
||||
[IO.Directory]::Delete($stagedCultLibPackage, $true)
|
||||
}
|
||||
Copy-Item -LiteralPath $builtCultLibPackage -Destination $stagedCultLibPackage -Recurse
|
||||
|
||||
$runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss")
|
||||
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
|
||||
$resultsPath = Join-Path $runRoot "unity-editmode-results.xml"
|
||||
|
|
@ -39,16 +27,10 @@ $arguments = @(
|
|||
"-assemblyNames", "GameCult.Eve.UnityUIToolkit.Tests",
|
||||
"-testResults", $resultsPath, "-logFile", $logPath
|
||||
)
|
||||
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -PassThru -WindowStyle Hidden
|
||||
if (-not $process.WaitForExit([int][TimeSpan]::FromMinutes(10).TotalMilliseconds)) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden
|
||||
if ($process.ExitCode -ne 0) {
|
||||
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))."
|
||||
}
|
||||
$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"
|
||||
throw "EveUnity UI Toolkit tests failed with exit code $($process.ExitCode)"
|
||||
}
|
||||
|
||||
[xml]$results = Get-Content -LiteralPath $resultsPath -Raw
|
||||
|
|
|
|||
Reference in a new issue