Compare commits

..
31 changed files with 475 additions and 3725 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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);
}

View file

@ -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",

View file

@ -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",

View file

@ -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);
}

View file

@ -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": {

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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; }
}
}
}

View file

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 61d76e98f2054d86bf8e7480d4411c39
timeCreated: 1787039650

View file

@ -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);
}
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7f7cb06716fd4f319ade147a69acb74a

View file

@ -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; }
}
}
}

View file

@ -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();
}
}

View file

@ -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": [],

View file

@ -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; }
}
}

View file

@ -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; }

View file

@ -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; }

View file

@ -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;

View file

@ -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)!;
}
}

View file

@ -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();
}
}
}
}

View file

@ -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"

View file

@ -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);

View file

@ -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]

View file

@ -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",

View file

@ -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 }

View file

@ -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"

View file

@ -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"

View file

@ -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