Compare commits

...
Author SHA1 Message Date
Meta Vens
92f0dbbc9b Align EveUnity release consumer closure 2026-08-26 21:17:04 +02:00
Meta Vens
d1663b36d1 Prove EveUnity receipt trust boundaries 2026-08-26 21:03:27 +02:00
Meta Vens
f0afa7681b Admit EveUnity transactional presentation authority 2026-08-26 20:36:38 +02:00
Meta Vens
959641c3a5 Lease immutable Eve asset catalog generations 2026-08-19 10:14:37 +02:00
Meta Vens
f3b5076c6d Retry transient Eve asset presentation failures 2026-08-19 10:04:30 +02:00
Meta Vens
da5bc22e16 Retry failed Eve presentation candidates 2026-08-19 09:56:36 +02:00
Meta Vens
47122fa53e Keep pinned Eve asset catalogs stable 2026-08-19 09:50:50 +02:00
Meta Vens
e44c77b4e8 Bind Eve finality to base and asset generations 2026-08-19 09:36:55 +02:00
Meta Vens
44923edc22 Keep embedded surfaces out of base finality 2026-08-19 09:19:04 +02:00
Meta Vens
1b6701456b Bind receipts to mounted Eve presentations 2026-08-19 09:10:37 +02:00
Meta Vens
a11b8f7d45 Bind asset candidates to one catalog lease 2026-08-19 08:18:57 +02:00
Meta Vens
12637367e1 Authenticate cross-Verse asset sources 2026-08-19 08:09:59 +02:00
Meta Vens
bdc942db21 Commit Eve asset generations atomically 2026-08-19 07:57:33 +02:00
Meta Vens
56aba155c2 Qualify Eve world asset sources 2026-08-19 07:31:25 +02:00
Meta Vens
6fe5a789fe Reject receipts for different Eve invocations 2026-08-19 04:32:14 +02:00
Meta Vens
baf9df618e Keep pending Eve commands alive until finality 2026-08-19 04:03:00 +02:00
Meta Vens
039c979e72 Keep inventory previews display-only 2026-08-19 03:50:02 +02:00
Meta Vens
781e612c23 Preserve navigation authority through Unity discovery 2026-08-19 03:31:16 +02:00
Meta Vens
ef83ba78bb Align Eve Unity with CultLib 1.0.56 2026-08-19 02:37:18 +02:00
Meta Vens
abfbb122ee Keep navigation connections bound through presentation teardown 2026-08-19 02:17:56 +02:00
Meta Vens
2fd5f4bbb9 Keep navigation shutdown on the outgoing Verse 2026-08-19 01:44:29 +02:00
Meta Vens
909ce43727 Restore provider route before presentation rollback 2026-08-19 01:01:40 +02:00
Meta Vens
7ba4041320 Make Eve navigation one rollbackable presentation transaction 2026-08-19 00:22:23 +02:00
Meta Vens
07f1df9cf4 Keep mounted provider events isolated while staging 2026-08-19 00:02:13 +02:00
Meta Vens
49f49203c8 Stage Eve navigation presentation before commit 2026-08-18 23:50:14 +02:00
Meta Vens
ccc5a2605c Make Eve provider navigation transactional 2026-08-18 23:09:33 +02:00
Meta Vens
de91053dd4 Make Eve navigation and Hangar lowering transactional 2026-08-18 20:54:14 +02:00
Meta Vens
e8e116ecc4 Carry authenticated Verse transport through Unity 2026-08-18 20:17:13 +02:00
Meta Vens
ae48ea031d Thread CultMesh trust through Unity provider discovery 2026-08-18 19:41:25 +02:00
Meta Vens
758f686032 Recover Unity commands from durable provider receipts 2026-08-18 19:03:06 +02:00
Meta Vens
1354dac447 Keep Unity commands pending until provider receipts 2026-08-18 17:47:34 +02:00
Meta Vens
cec589d94d Pin a reproducible EveUnity git consumer 2026-08-18 10:50:24 +02:00
Meta Vens
82b48cd089 Test EveUnity through isolated package consumers 2026-08-18 10:48:06 +02:00
Meta Vens
a51723bd57 Keep CultMesh I/O off the Unity main thread 2026-08-18 10:08:03 +02:00
Meta Vens
f67632233a Carry CultMesh Verse and provider identity separately 2026-08-18 07:27:13 +02:00
Meta Vens
83f9d2e0cc Align Unity scene lowering dependencies 2026-08-18 05:32:32 +02:00
Meta Vens
136d705707 Release Unity inventory placement lowering 2026-08-18 05:31:10 +02:00
Meta Vens
3af959bf4f Lower inventory placement previews in Unity 2026-08-18 05:28:23 +02:00
31 changed files with 3725 additions and 475 deletions

3
.gitignore vendored
View file

@ -9,3 +9,6 @@ TestProject/UserSettings/
obj/
*.csproj
*.sln
packages/org.gamecult.eve.surface/bin~/
packages/org.gamecult.eve.surface/obj~/
packages/org.gamecult.eve.surface/Directory.Build.props.meta

View file

@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
rendezvousEndpoint,
provider.Selection.VerseId);
provider.Selection.VerseId,
provider.Selection.AuthorityRuntimeId);
var quicDeadline = Time.realtimeSinceStartup + 15f;
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
yield return new WaitForSecondsRealtime(0.05f);
@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
string rendezvousEndpoint,
string verseId)
string verseId,
string authorityRuntimeId)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var client = new CultMeshClient(new CultMeshClientOptions
@ -1211,7 +1213,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
new CultMeshNativeQuicRealtimeTransportConnector()
}
});
using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token);
using var session = await client.ConnectRealtimeAsync(
new CultMeshSessionTarget(verseId, authorityRuntimeId),
timeout.Token);
return await session.ReceiveAsync(timeout.Token);
}

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#cultlib-unity-v1.0.45",
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
"org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
"org.gamecult.eve.surface": "https://github.com/GameCult/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"
"org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9",
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9"
},
"testables": [
"org.gamecult.eve.unity-scene",

View file

@ -119,11 +119,11 @@
}
},
"org.gamecult.cultlib": {
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.45",
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "03840d0430bc727f1322861a1ab46b396eaca860"
"hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e"
},
"org.gamecult.eve.plugin-fields": {
"version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
@ -135,37 +135,37 @@
"hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
},
"org.gamecult.eve.surface": {
"version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#eve-surface-v0.3.0",
"version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.cultlib": "1.0.45"
"org.gamecult.cultlib": "1.0.56"
},
"hash": "fe0dddf28267decbb416325a3d0b2c62432825c2"
"hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825"
},
"org.gamecult.eve.unity-scene": {
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#50cd42137a1b94fa8c578918463ddcd822d2bfc9",
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.cultlib": "1.0.45",
"org.gamecult.cultlib": "1.0.56",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.3.0",
"org.gamecult.eve.unity-uitoolkit": "0.1.3",
"org.gamecult.eve.surface": "0.3.6",
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.modules.particlesystem": "1.0.0"
},
"hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9"
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
},
"org.gamecult.eve.unity-uitoolkit": {
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#50cd42137a1b94fa8c578918463ddcd822d2bfc9",
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.eve.surface": "0.3.0"
"org.gamecult.eve.surface": "0.3.6"
},
"hash": "50cd42137a1b94fa8c578918463ddcd822d2bfc9"
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
},
"com.unity.modules.assetbundle": {
"version": "1.0.0",

View file

@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
rendezvousEndpoint,
provider.Selection.VerseId);
provider.Selection.VerseId,
provider.Selection.AuthorityRuntimeId);
var quicDeadline = Time.realtimeSinceStartup + 15f;
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
yield return new WaitForSecondsRealtime(0.05f);
@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
private static async Task<CultMeshRealtimeFrame> ReceiveAdvertisedRealtimeFrameAsync(
string rendezvousEndpoint,
string verseId)
string verseId,
string authorityRuntimeId)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var client = new CultMeshClient(new CultMeshClientOptions
@ -1211,7 +1213,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
new CultMeshNativeQuicRealtimeTransportConnector()
}
});
using var session = await client.ConnectRealtimeAsync(verseId, timeout.Token);
using var session = await client.ConnectRealtimeAsync(
new CultMeshSessionTarget(verseId, authorityRuntimeId),
timeout.Token);
return await session.ReceiveAsync(timeout.Token);
}

View file

@ -137,7 +137,7 @@
"depth": 0,
"source": "local",
"dependencies": {
"org.gamecult.cultlib": "1.0.45"
"org.gamecult.cultlib": "1.0.56"
}
},
"org.gamecult.eve.unity-scene": {
@ -145,10 +145,10 @@
"depth": 0,
"source": "local",
"dependencies": {
"org.gamecult.cultlib": "1.0.45",
"org.gamecult.cultlib": "1.0.56",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.3.0",
"org.gamecult.eve.unity-uitoolkit": "0.1.3",
"org.gamecult.eve.surface": "0.3.6",
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.modules.particlesystem": "1.0.0"
@ -159,7 +159,7 @@
"depth": 0,
"source": "local",
"dependencies": {
"org.gamecult.eve.surface": "0.3.0"
"org.gamecult.eve.surface": "0.3.6"
}
},
"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.100`, commit
`3632bfb3354065b78a90b921d7025c4c3fa92be4`, and
`org.gamecult.eve.surface` `0.2.4`, commit
`e08fa08335f99e9edddeb706912eecfad07cb281`;
- `org.gamecult.eve.unity-scene` `0.3.134`, commit
`d1663b36d12b60ff5c7249135eff48edf6227ca9`, and
`org.gamecult.eve.surface` `0.3.6`, commit
`96839ad34c8d464ef622d8bbdd5d277e1ca9d825`;
- `org.gamecult.eve.plugin-fields` `0.2.3`, commit
`c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`;
- `org.gamecult.eve.unity-uitoolkit` `0.1.1`, commit
`4d0cbe0185bdc4fc65eb63503a7c5cb578539669`;
- `org.gamecult.cultlib` `1.0.43`, commit
`f67f5122ed1bd11da016e7b820ed60145ccd0299`.
- `org.gamecult.eve.unity-uitoolkit` `0.1.9`, commit
`d1663b36d12b60ff5c7249135eff48edf6227ca9`;
- `org.gamecult.cultlib` `1.0.56`, commit
`334e60f1928b4212a29dd8b0d19b2c099fe6365e`.
The hand-run integration gates are the cold lowering proof and the warm
full-session proof:
```powershell
pwsh -File .\scripts\run-aetheria-daemon-world-witness.ps1 `
-CultLibRoot E:\Projects\CultLib-codex-cultmesh-reliability `
-CultLibRoot E:\Projects\CultLib `
-CacheState cold `
-AssetCacheDirectory artifacts\cultlib-1043-scene-03100-cold-cache `
-OutputDirectory artifacts\cultlib-1043-scene-03100-cold `
-AssetCacheDirectory artifacts\admitted-release-cold-cache `
-OutputDirectory artifacts\admitted-release-cold `
-SkipAssetBundleBuild
```
@ -31,9 +31,13 @@ Both connect directly to the Aetheria daemon. Control, subscriptions, commands,
and receipts use `cultnet+tcp`; provider files use `cultmesh-content+tcp`;
same-machine entity state uses a mapped SoA body; and the released native QUIC
client receives the advertised latest-only realtime body. No Odin hop or
Aetheria-specific gameplay code participates in the Unity runtime.
Aetheria-specific gameplay code participates in the Unity runtime. This
admission pass proves the package graph with the isolated EditMode consumer; it
does not claim a new rendered end-to-end witness.
The current cold proof is
## Historical witness ledger
The retained cold proof is
`artifacts/cultlib-1043-scene-03100-cold5/runtime-witness.cold.json`. It began
with no bodies or partials and passed in 57.8 seconds end to end (14.9 seconds
inside PlayMode). The client materialized nine requested bundles totaling
@ -45,7 +49,7 @@ unloaded. This cold profile owns content transfer, world lowering, fog and
Stardust execution, and pilot/map camera isolation. It deliberately issues no
gameplay command into the paused Terminus fixture.
The current warm full-session proof is
The retained warm full-session proof is
`artifacts/cultlib-1043-scene-03100-warm/runtime-witness.warm.json`. It passed in
70.7 seconds end to end (27.9 seconds inside PlayMode). The generic client docks,
buys and sells one typed item, resumes and undocks, moves, targets, aims, holds
@ -63,8 +67,6 @@ visual-lever and shader-calibration issues, not concealed transport failures.
The map capture is clean and contains only map-channel glyphs on its dark map
background.
## Historical witness ledger
An earlier released-package cold world witness passed with:
- `org.gamecult.eve.unity-scene` `0.3.63`, commit

View file

@ -76,6 +76,13 @@ 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
@ -98,8 +105,10 @@ modes into one camera opinion or infer a product camera from entity kind.
## Shared Paths
Initial connect asks one rendezvous for Verses, selects a provider surface by
semantic kind, then leases it through the selected stable provider identity.
Initial preparation asks one rendezvous for Verses, selects an advertised
authority runtime and provider surface by semantic kind, then leases it through
the explicit Verse/authority-runtime target. The Eve provider ID remains a
separate surface-owner identity.
Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain
inside the same identity-owned session. Movement, focus, target, and action
input all become `gamecult.eve.command_invocation.v1` through the

View file

@ -16,20 +16,47 @@ owned by their native lowerers and are not duplicated into empty UI elements.
networked client. Give it one Verse rendezvous endpoint and it discovers an
advertised `interactive-world`, constructs the generic CultMesh transport, and
provides every bootstrap port. Provider, surface, and Verse IDs are optional
selection filters; they are not product knowledge required by the client.
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.
selection filters; they are not product knowledge required by the client. The
selected Verse ID and its advertised authority-runtime ID form the stable
CultMesh session target. The Eve provider ID separately names the UI owner found
through that target. Physical routes are resolved and re-resolved inside
`CultMeshClient`; they are never published as provider selection state or
cached as Unity authority. The optional local path is only an ephemeral
content/body cache, not a replicated gameplay database.
Await `PrepareAsync()` before mounting the bootstrap or reading provider ports.
Discovery and session setup never run synchronously from Unity lifecycle or
input getters, so an unavailable provider cannot stall the editor main thread.
Live entity, field, and receipt polling likewise runs as one non-overlapping
background operation. Unity's main thread only applies completed typed
documents, keeping Play, Pause, inspection, and rendering responsive while a
provider is slow or temporarily unreachable.
It owns discovery, initial document reads, content transfer, asynchronous asset
bundle loading, and live lease installation. Once preparation succeeds,
`Mount()` and `Connect()` perform no remote work. Input submission only enters a
bounded asynchronous outbox: one-shot commands retain their idempotency keys,
while movement and look values coalesce to the latest value per entity. A
successful send is not completion: the command remains pending and retries with
the same idempotency key until its canonical provider receipt arrives. Failed
or unacknowledged commands rotate behind other due work, so one poison command
cannot starve the outbox.
Live entity, field, and receipt polling likewise remains non-blocking. Unity's
main thread applies completed typed documents and assets; it never waits for a
CultMesh task, keeping Play, Pause, input, inspection, and rendering responsive
while a provider is slow or temporarily unreachable.
An Eve world may qualify its asset catalog with a different provider, Verse,
authority runtime, and Odin rendezvous set than the surface provider. The
transport resolves that exact asset session and commits assets as one generation:
catalog, verified bundles, leases, lookups, render policy, and surface lowering
are prepared off to the side, then swapped together. A failed or superseded
candidate is disposed without unloading or rewriting the mounted generation.
The mounted provider's trust policy authenticates only that provider. A
provider-qualified asset source in another Verse/runtime uses the configured
cross-target Odin trust for its catalog, subscription, manifests, content, and
bundles; local-development trust does not cross that boundary.
The candidate opens its catalog lease before preload, observes updates in a
candidate-owned buffer, and verifies the leased version again before commit.
An update that lands during preload therefore rebuilds the candidate instead
of disappearing between a one-shot read and a later subscription.
Navigation keeps the outgoing presentation quiesced until the candidate route
is ready, then commits the provider route before activating the candidate.
Presentation connections are provider-owned leases: disposing the outgoing
host releases only its lease and cannot disconnect the newly committed Verse.
The package never imports provider product assemblies.
@ -149,3 +176,13 @@ authority.
Native volume programs may advertise a `cameraToWorld` matrix port when their
procedural passes require an explicit camera transform instead of relying on
render-pipeline globals.
Command finality is presentation-causal. When a terminal receipt names a
`sourceVersion` newer than the mounted surface, EveUnity keeps the command and
its exact receipt lease alive, makes the obsolete surface read-only, and emits
the terminal receipt only after that surface generation and its qualified asset
catalog have committed together. Asset preparation failure therefore cannot
leave an accepted Verse switch paired with actionable controls from the prior
Verse. Embedded surfaces may recompose the visible tree, but their versions are
derived rendering inputs and cannot satisfy or clear the advertised base
surface's finality barrier.

View file

@ -0,0 +1,296 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
#nullable enable
namespace GameCult.Eve.UnityScene
{
/// <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

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

View file

@ -0,0 +1,49 @@
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

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

View file

@ -3,6 +3,7 @@ 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;
@ -39,10 +40,18 @@ 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>();
@ -85,7 +94,9 @@ namespace GameCult.Eve.UnityScene
string surfaceId = "",
string verseId = "",
string requiredSurfaceKind = "interactive-world",
string clientRuntimeId = "eve-unity")
string clientRuntimeId = "eve-unity",
CultMeshAuthorityTrustPolicy? authorityTrust = null,
CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null)
{
if (_bridge != null || _preparation != null)
throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration.");
@ -97,11 +108,17 @@ 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()
@ -109,13 +126,21 @@ namespace GameCult.Eve.UnityScene
if (_bridge != null)
return Task.CompletedTask;
if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted)
_preparation = PrepareTransportAsync();
{
_preparationLifetime?.Dispose();
_preparationLifetime = new CancellationTokenSource();
_preparation = PrepareTransportAsync(_preparationLifetime.Token);
}
return _preparation;
}
public void Disconnect()
{
_bridge?.Disconnect();
if (_connectionLeaseCount <= 0)
return;
_connectionLeaseCount--;
if (_connectionLeaseCount == 0)
_bridge?.Disconnect();
}
public async Task NavigateAsync(EveUnitySceneNavigationTarget target)
@ -126,29 +151,107 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(target.SurfaceId))
throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target));
ReleaseTransport();
var navigationEndpoint = target.RendezvousEndpoints
.FirstOrDefault(endpoint => !string.IsNullOrWhiteSpace(endpoint));
if (!string.IsNullOrWhiteSpace(navigationEndpoint))
rendezvousEndpoint = navigationEndpoint;
var endpoints = (target.RendezvousEndpoints ?? Array.Empty<string>())
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint))
.Select(endpoint => endpoint.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
if (endpoints.Length == 0 && !string.IsNullOrWhiteSpace(rendezvousEndpoint))
endpoints = new[] { rendezvousEndpoint };
if (endpoints.Length == 0)
throw new InvalidOperationException("Eve provider navigation requires at least one Odin rendezvous endpoint.");
var requiredSurfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind)
? "interactive-world"
: target.SurfaceKind;
var prepared = await PrepareCandidateAsync(
endpoints,
target.ProviderId,
target.SurfaceId,
requiredSurfaceKind,
target.VerseId,
target.AuthorityRuntimeId,
_navigationAuthorityTrust,
_navigationAuthorityTrust,
CancellationToken.None).ConfigureAwait(false);
StagePrepared(prepared);
rendezvousEndpoint = prepared.Selection.RendezvousEndpoint;
verseFilter = target.VerseId;
providerFilter = target.ProviderId;
surfaceFilter = target.SurfaceId;
surfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) ? "interactive-world" : target.SurfaceKind;
await PrepareAsync();
surfaceKind = requiredSurfaceKind;
_authorityTrust = _navigationAuthorityTrust;
_preparation = Task.CompletedTask;
}
public void CommitNavigation()
{
var previous = _previousProvider;
if (previous == null) return;
if (_navigationRouteCommitted) return;
if (previous.Bridge != null)
previous.Bridge.ReceiptAvailable -= ForwardReceipt;
AttachPrepared(_transport, _bridge);
_navigationRouteCommitted = true;
}
public void FinalizeNavigation()
{
var previous = _previousProvider;
if (previous == null) return;
_previousProvider = null;
_navigationRouteCommitted = false;
SafeDispose(previous.Bridge);
SafeDispose(previous.Transport);
}
public void RollbackNavigation()
{
var previous = _previousProvider;
if (previous == null) return;
if (previous.Bridge != null)
previous.Bridge.ReceiptAvailable -= ForwardReceipt;
DetachPrepared(_transport, _bridge);
var rejectedBridge = _bridge;
var rejectedTransport = _transport;
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
while (_pendingFields.TryDequeue(out _)) { }
Selection = previous.Selection;
_transport = previous.Transport;
_bridge = previous.Bridge;
rendezvousEndpoint = previous.RendezvousEndpoint;
verseFilter = previous.VerseFilter;
providerFilter = previous.ProviderFilter;
surfaceFilter = previous.SurfaceFilter;
surfaceKind = previous.SurfaceKind;
_authorityTrust = previous.AuthorityTrust;
AttachPrepared(_transport, _bridge);
_previousProvider = null;
_navigationRouteCommitted = false;
_preparation = Task.CompletedTask;
SafeDispose(rejectedBridge);
SafeDispose(rejectedTransport);
}
public void Refresh()
{
if (Bridge.IsConnected)
Bridge.Refresh();
var activeBridge = _navigationRouteCommitted
? Bridge
: _previousProvider?.Bridge ?? Bridge;
if (activeBridge.IsConnected)
activeBridge.Refresh();
else
Bridge.Connect();
activeBridge.Connect();
}
public void Submit(EveSurfaceCommandRequest request)
{
Bridge.Submit(request);
// Navigation preparation is not a command-routing commit. The mounted
// presentation continues to target its old provider until the visual
// candidate has been lowered and accepted by the bootstrap.
(_navigationRouteCommitted ? Bridge : _previousProvider?.Bridge ?? Bridge).Submit(request);
}
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
@ -184,6 +287,7 @@ namespace GameCult.Eve.UnityScene
private void ReleaseTransport()
{
_preparationLifetime?.Cancel();
if (_bridge != null)
{
_bridge.DocumentAvailable -= ForwardDocument;
@ -191,16 +295,24 @@ namespace GameCult.Eve.UnityScene
}
_bridge?.Dispose();
_transport?.Dispose();
_previousProvider?.Bridge?.Dispose();
_previousProvider?.Transport?.Dispose();
_previousProvider = null;
_navigationRouteCommitted = false;
_connectionLeaseCount = 0;
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
while (_pendingFields.TryDequeue(out _)) { }
_bridge = null;
_transport = null;
_preparation = null;
_preparationLifetime?.Dispose();
_preparationLifetime = null;
Selection = null;
}
private void Update()
{
_previousProvider?.Transport?.PumpLiveEvents();
_transport?.PumpLiveEvents();
EveFieldsSplatsDocument? latestFields = null;
while (_pendingFields.TryDequeue(out var fields))
@ -228,7 +340,7 @@ namespace GameCult.Eve.UnityScene
}
}
private async Task PrepareTransportAsync()
private async Task PrepareTransportAsync(CancellationToken cancellationToken)
{
var elapsed = Stopwatch.StartNew();
if (_transport != null)
@ -236,38 +348,183 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync(
rendezvousEndpoint,
var prepared = await PrepareCandidateAsync(
new[] { rendezvousEndpoint },
providerFilter,
surfaceFilter,
surfaceKind,
verseFilter);
TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
elapsed.Restart();
var 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");
verseFilter,
"",
_authorityTrust,
_navigationAuthorityTrust,
cancellationToken).ConfigureAwait(false);
TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
AdoptPrepared(prepared);
}
private async Task<PreparedProvider> PrepareCandidateAsync(
IReadOnlyList<string> endpoints,
string requiredProviderId,
string requiredSurfaceId,
string requiredSurfaceKind,
string requiredVerseId,
string requiredAuthorityRuntimeId,
CultMeshAuthorityTrustPolicy trust,
CultMeshAuthorityTrustPolicy crossTargetTrust,
CancellationToken cancellationToken)
{
var failures = new List<string>();
foreach (var endpoint in endpoints)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var selection = await new EveUnityCultMeshProviderDiscovery(trust).DiscoverAsync(
endpoint,
requiredProviderId,
requiredSurfaceId,
requiredSurfaceKind,
requiredVerseId,
cancellationToken,
requiredAuthorityRuntimeId).ConfigureAwait(false);
var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory)
? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}")
: cacheDirectory;
var transport = new EveUnityCultMeshLiveProviderTransport(
resolvedCachePath,
selection.RendezvousEndpoint,
selection.VerseId,
selection.AuthorityRuntimeId,
selection.ProviderId,
selection.SurfaceId,
runtimeId,
authorityTrust: trust,
crossTargetAuthorityTrust: crossTargetTrust);
try
{
await transport.PrepareAsync(cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return new PreparedProvider(selection, transport, new EveUnitySceneLiveProviderBridge(transport));
}
catch
{
transport.Dispose();
throw;
}
}
catch (Exception error) when (error is not OperationCanceledException)
{
failures.Add($"{endpoint}: {error.Message}");
}
}
throw new InvalidOperationException(
$"No configured Odin endpoint prepared Verse '{requiredVerseId}' surface '{requiredSurfaceId}'. " +
string.Join(" | ", failures));
}
private void AdoptPrepared(PreparedProvider prepared)
{
if (_previousProvider != null)
throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first.");
var oldBridge = _bridge;
var oldTransport = _transport;
DetachPrepared(oldTransport, oldBridge);
Selection = prepared.Selection;
_transport = prepared.Transport;
_bridge = prepared.Bridge;
AttachPrepared(_transport, _bridge);
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
while (_pendingFields.TryDequeue(out _)) { }
oldBridge?.Dispose();
oldTransport?.Dispose();
}
private void StagePrepared(PreparedProvider prepared)
{
if (_previousProvider != null)
{
prepared.Bridge.Dispose();
prepared.Transport.Dispose();
throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first.");
}
_previousProvider = new PreviousProvider(
Selection,
_transport,
_bridge,
rendezvousEndpoint,
verseFilter,
providerFilter,
surfaceFilter,
surfaceKind,
_authorityTrust);
_navigationRouteCommitted = false;
DetachPrepared(_transport, _bridge);
if (_previousProvider.Bridge != null)
_previousProvider.Bridge.ReceiptAvailable += ForwardReceipt;
Selection = prepared.Selection;
_transport = prepared.Transport;
_bridge = prepared.Bridge;
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
while (_pendingFields.TryDequeue(out _)) { }
}
private void AttachPrepared(
EveUnityCultMeshLiveProviderTransport? transport,
EveUnitySceneLiveProviderBridge? bridge)
{
if (transport != null)
{
transport.EntityViewAvailable += QueueEntityView;
transport.FieldsSplatsAvailable += QueueFields;
}
if (bridge != null)
{
bridge.DocumentAvailable += ForwardDocument;
bridge.ReceiptAvailable += ForwardReceipt;
}
}
private void DetachPrepared(
EveUnityCultMeshLiveProviderTransport? transport,
EveUnitySceneLiveProviderBridge? bridge)
{
if (transport != null)
{
transport.EntityViewAvailable -= QueueEntityView;
transport.FieldsSplatsAvailable -= QueueFields;
}
if (bridge == null) return;
bridge.DocumentAvailable -= ForwardDocument;
bridge.ReceiptAvailable -= ForwardReceipt;
}
private void QueueEntityView(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease) =>
_pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
private void QueueFields(EveFieldsSplatsDocument fields) => _pendingFields.Enqueue(fields);
private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) =>
DocumentAvailable?.Invoke(document);
private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) =>
ReceiptAvailable?.Invoke(receipt);
private static void SafeDispose(IDisposable? disposable)
{
try
{
disposable?.Dispose();
}
catch (Exception error)
{
Debug.LogWarning($"Eve provider cleanup failed after navigation ownership changed: {error}");
}
}
private static void TraceStartup(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal))
@ -292,5 +549,57 @@ namespace GameCult.Eve.UnityScene
public EveEntitySoaViewDocument Document { get; }
public ICultMeshBodyReadLease Lease { get; }
}
private sealed class PreparedProvider
{
public PreparedProvider(
EveUnityCultMeshProviderSelection selection,
EveUnityCultMeshLiveProviderTransport transport,
EveUnitySceneLiveProviderBridge bridge)
{
Selection = selection;
Transport = transport;
Bridge = bridge;
}
public EveUnityCultMeshProviderSelection Selection { get; }
public EveUnityCultMeshLiveProviderTransport Transport { get; }
public EveUnitySceneLiveProviderBridge Bridge { get; }
}
private sealed class PreviousProvider
{
public PreviousProvider(
EveUnityCultMeshProviderSelection? selection,
EveUnityCultMeshLiveProviderTransport? transport,
EveUnitySceneLiveProviderBridge? bridge,
string rendezvousEndpoint,
string verseFilter,
string providerFilter,
string surfaceFilter,
string surfaceKind,
CultMeshAuthorityTrustPolicy authorityTrust)
{
Selection = selection;
Transport = transport;
Bridge = bridge;
RendezvousEndpoint = rendezvousEndpoint;
VerseFilter = verseFilter;
ProviderFilter = providerFilter;
SurfaceFilter = surfaceFilter;
SurfaceKind = surfaceKind;
AuthorityTrust = authorityTrust;
}
public EveUnityCultMeshProviderSelection? Selection { get; }
public EveUnityCultMeshLiveProviderTransport? Transport { get; }
public EveUnitySceneLiveProviderBridge? Bridge { get; }
public string RendezvousEndpoint { get; }
public string VerseFilter { get; }
public string ProviderFilter { get; }
public string SurfaceFilter { get; }
public string SurfaceKind { get; }
public CultMeshAuthorityTrustPolicy AuthorityTrust { get; }
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using GameCult.Mesh;
@ -14,23 +15,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; }
@ -38,21 +39,22 @@ namespace GameCult.Eve.UnityScene
public sealed class EveUnityCultMeshProviderDiscovery
{
public EveUnityCultMeshProviderSelection Discover(
string rendezvousEndpoint,
string providerId = "",
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "") =>
DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId)
.GetAwaiter().GetResult();
private readonly CultMeshAuthorityTrustPolicy _authorityTrust;
public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null)
{
_authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote);
}
public async Task<EveUnityCultMeshProviderSelection> DiscoverAsync(
string rendezvousEndpoint,
string providerId = "",
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "")
string verseId = "",
CancellationToken cancellationToken = default,
string requiredAuthorityRuntimeId = "")
{
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
@ -60,13 +62,14 @@ namespace GameCult.Eve.UnityScene
CultMeshVerseCatalogResponseMessage response;
try
{
response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync(
response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync(
rendezvousEndpoint,
new CultMeshVerseCatalogRequestMessage
{
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
TransportVersion = "cultmesh.v0"
});
cancellationToken.ThrowIfCancellationRequested();
}
catch (Exception error)
{
@ -91,53 +94,74 @@ namespace GameCult.Eve.UnityScene
var observed = new List<string>();
using var mesh = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { rendezvousEndpoint }
RendezvousEndpoints = new[] { rendezvousEndpoint },
Discovery = EveUnityCultMeshConnectivity.Discovery(),
Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust },
Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(),
ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors()
});
foreach (var candidate in candidates)
{
try
foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds(
candidate.AuthorityRuntimeIds,
requiredAuthorityRuntimeId))
{
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;
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;
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}");
return new EveUnityCultMeshProviderSelection(
rendezvousEndpoint,
candidate.VerseId,
authorityRuntimeId,
advertisement.Document.ProviderId,
advertisement.Surface.SurfaceId,
advertisement.Surface.SurfaceKind);
}
catch (Exception error)
{
failures.Add($"{candidate.VerseId}/{authorityRuntimeId}: {error.Message}");
}
}
}
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'";
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'";
var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}";
var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}";
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}");
}
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,7 +16,8 @@
"GameCult.Caching.MessagePack.dll",
"GameCult.Mesh.dll",
"GameCult.Mesh.Quic.Native.dll",
"GameCult.Networking.dll"
"GameCult.Networking.dll",
"GameCult.Networking.WebSockets.dll"
],
"autoReferenced": true,
"defineConstraints": [],

View file

@ -30,8 +30,13 @@ 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)
{
@ -44,7 +49,12 @@ namespace GameCult.Eve.UnityScene
BindNavigation();
}
private void OnDestroy() => UnbindNavigation();
private void OnDestroy()
{
UnbindNavigation();
_ownedPresentation?.Dispose();
_ownedPresentation = null;
}
private void BindNavigation()
{
@ -77,12 +87,67 @@ 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();
Mount();
staged = PrepareStagedPresentation();
previousRoot = sceneRoot;
previousRootWasActive = previousRoot == null || previousRoot.gameObject.activeSelf;
if (previousRoot != null && !ReferenceEquals(previousRoot, transform) &&
!ReferenceEquals(previousRoot, staged.SceneRoot))
previousRoot.gameObject.SetActive(false);
navigable.CommitNavigation();
swap = ActivateStagedPresentation(staged, previousRootWasActive);
navigable.FinalizeNavigation();
swap.Complete();
swap = null;
staged = null;
LastNavigationFailure = null;
}
catch (Exception error)
{
var providerRestored = false;
try
{
navigable.RollbackNavigation();
providerRestored = true;
}
catch (Exception rollbackError)
{
Debug.LogError($"Eve provider route rollback failed; the previous presentation will remain inactive. {rollbackError}");
}
if (providerRestored)
{
try
{
if (swap != null)
swap.Rollback();
else if (previousRoot != null)
previousRoot.gameObject.SetActive(previousRootWasActive);
}
catch (Exception rollbackError)
{
Debug.LogError($"Eve previous presentation rollback failed after provider restoration. {rollbackError}");
}
}
try
{
staged?.Dispose();
}
catch (Exception cleanupError)
{
Debug.LogWarning($"Eve rejected presentation cleanup failed. {cleanupError}");
}
var failure = new EveUnitySceneNavigationFailure(target, error.Message, DateTimeOffset.UtcNow);
LastNavigationFailure = failure;
NavigationFailed?.Invoke(failure);
Debug.LogError($"Eve provider navigation failed; the current surface remains mounted. {error}");
}
finally
{
@ -90,6 +155,255 @@ 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();
@ -243,4 +557,21 @@ namespace GameCult.Eve.UnityScene
return null;
}
}
public sealed class EveUnitySceneNavigationFailure
{
public EveUnitySceneNavigationFailure(
EveUnitySceneNavigationTarget target,
string diagnostic,
DateTimeOffset failedAtUtc)
{
Target = target;
Diagnostic = diagnostic;
FailedAtUtc = failedAtUtc;
}
public EveUnitySceneNavigationTarget Target { get; }
public string Diagnostic { get; }
public DateTimeOffset FailedAtUtc { get; }
}
}

View file

@ -238,6 +238,12 @@ namespace GameCult.Eve.UnityScene
public interface IEveUnityNavigableProvider
{
Task NavigateAsync(EveUnitySceneNavigationTarget target);
void CommitNavigation();
void FinalizeNavigation();
void RollbackNavigation();
}
public sealed class EveUnitySceneNavigationTarget
@ -247,9 +253,11 @@ namespace GameCult.Eve.UnityScene
string providerId,
string surfaceId,
string surfaceKind,
IReadOnlyList<string>? rendezvousEndpoints = null)
IReadOnlyList<string>? rendezvousEndpoints = null,
string authorityRuntimeId = "")
{
VerseId = verseId ?? "";
AuthorityRuntimeId = authorityRuntimeId ?? "";
ProviderId = providerId ?? "";
SurfaceId = surfaceId ?? "";
SurfaceKind = surfaceKind ?? "";
@ -257,6 +265,7 @@ 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,7 +172,11 @@ namespace GameCult.Eve.UnityScene
worldRoot.GetProp("exposureAdaptation", "progressive"),
ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f),
ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f),
worldRoot.GetProp("colorGradingSpace"));
worldRoot.GetProp("colorGradingSpace"),
worldRoot.GetProp("assetProviderId"),
worldRoot.GetProp("assetVerseId"),
worldRoot.GetProp("assetAuthorityRuntimeId"),
ParseStringList(worldRoot.GetProp("assetRendezvousEndpoints")));
}
private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) =>
@ -270,7 +274,7 @@ namespace GameCult.Eve.UnityScene
private static IReadOnlyList<string> ParseStringList(string value)
{
return (value ?? "")
.Split(',')
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Trim())
.Where(part => !string.IsNullOrWhiteSpace(part))
.Distinct(StringComparer.Ordinal)
@ -443,7 +447,11 @@ namespace GameCult.Eve.UnityScene
string exposureAdaptation = "progressive",
float exposureSpeedUp = 2f,
float exposureSpeedDown = 1f,
string colorGradingSpace = "")
string colorGradingSpace = "",
string assetProviderId = "",
string assetVerseId = "",
string assetAuthorityRuntimeId = "",
IReadOnlyList<string>? assetRendezvousEndpoints = null)
{
WorldRootId = worldRootId ?? "";
StatePointerId = statePointerId ?? "";
@ -507,6 +515,10 @@ namespace GameCult.Eve.UnityScene
ExposureSpeedUp = exposureSpeedUp;
ExposureSpeedDown = exposureSpeedDown;
ColorGradingSpace = colorGradingSpace ?? "";
AssetProviderId = assetProviderId ?? "";
AssetVerseId = assetVerseId ?? "";
AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? "";
AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty<string>();
}
public string WorldRootId { get; }
@ -525,6 +537,14 @@ namespace GameCult.Eve.UnityScene
public string AssetManifest { get; }
public string AssetProviderId { get; }
public string AssetVerseId { get; }
public string AssetAuthorityRuntimeId { get; }
public IReadOnlyList<string> AssetRendezvousEndpoints { get; }
public string InputProfile { get; }
public string CameraRig { get; }

View file

@ -15,16 +15,20 @@ namespace GameCult.Eve.UnityScene
var document = host.GetComponent<UIDocument>();
if (document != null) return document;
var theme = Resources.Load<ThemeStyleSheet>(ThemeResource);
if (theme == null)
throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing.");
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 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;
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,11 +2,15 @@ using System;
using System.Reflection;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using GameCult.Eve.UnityScene;
using GameCult.Mesh;
using NUnit.Framework;
using UnityEngine;
@ -14,6 +18,324 @@ 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()
{
@ -26,11 +348,177 @@ 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()
{
@ -158,29 +646,16 @@ namespace GameCult.Eve.UnityScene.Tests
}
[Test]
public void PublishingCatalogRegistersBundlesWithoutMaterializingThem()
public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem()
{
var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"CurrentBundlePlatform",
BindingFlags.Static | BindingFlags.NonPublic);
var publishMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"PublishAssetCatalog",
BindingFlags.Instance | BindingFlags.NonPublic);
var bundlesField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_assetBundles",
BindingFlags.Instance | BindingFlags.NonPublic);
var selectionsField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_assetSelections",
BindingFlags.Instance | BindingFlags.NonPublic);
var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"ConfigureAssetCatalog",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(platformMethod, Is.Not.Null);
Assert.That(publishMethod, Is.Not.Null);
using var transport = new EveUnityCultMeshLiveProviderTransport(
Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"),
"cultnet+tcp://127.0.0.1:1",
"provider",
"provider",
"surface");
Assert.That(configureMethod, Is.Not.Null);
var variant = new EveAssetVariant(
"unity-scene",
(string)platformMethod!.Invoke(null, Array.Empty<object>())!,
@ -195,11 +670,229 @@ namespace GameCult.Eve.UnityScene.Tests
1,
DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) });
var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1);
publishMethod!.Invoke(transport, new object[] { catalog });
configureMethod!.Invoke(null, new[] { candidate, catalog });
Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero);
Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).Count, Is.EqualTo(1));
Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero);
Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1));
((IDisposable)candidate).Dispose();
}
[Test]
public void FailedAssetCandidatePreservesCommittedGeneration()
{
var transportType = typeof(EveUnityCultMeshLiveProviderTransport);
var generationField = transportType.GetField(
"_assetGeneration",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var configureMethod = transportType.GetMethod(
"ConfigureAssetCatalog",
BindingFlags.Static | BindingFlags.NonPublic)!;
using var transport = new EveUnityCultMeshLiveProviderTransport(
Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"),
"cultnet+tcp://127.0.0.1:1",
"verse-a",
"authority-a",
"provider-a",
"surface-a");
var initial = (IDisposable)generationField.GetValue(transport)!;
var committed = CreateAssetGeneration(
"verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 1);
var prefab = new GameObject("committed-a");
GenerationDictionary(committed, "Prefabs")["prefab.a"] = prefab;
initial.Dispose();
generationField.SetValue(transport, committed);
var invalidVariant = new EveAssetVariant(
"browser",
"web",
"url",
"https://example.invalid/a",
"sha256:00",
1,
"prefab.a");
var invalidCatalog = new EveAssetCatalogDocument(
"provider-b",
"catalog-b",
2,
DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) });
var rejected = CreateAssetGeneration(
"verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1);
var error = Assert.Throws<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,6 +3,7 @@ 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;
@ -32,6 +33,7 @@ namespace GameCult.Eve.UnityScene.Tests
"cultnet://127.0.0.1:3075",
"aetheria",
"aetheria",
"aetheria",
"aetheria.pilot");
});
}
@ -67,6 +69,7 @@ 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);
@ -102,6 +105,7 @@ 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);
@ -131,6 +135,7 @@ 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)!;
@ -161,6 +166,7 @@ namespace GameCult.Eve.UnityScene.Tests
"cultnet://127.0.0.1:3075",
"provider",
"provider",
"provider",
"provider.pilot");
var view = EntityLeaseDocument();
view.Sequence = 0;
@ -576,6 +582,10 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(projection.PlayableWorld.EntityBodyId, Is.EqualTo("eve:entity-soa:aetheria.daemon:pilot"));
Assert.That(projection.PlayableWorld.ZoneRenderPointerId, Is.EqualTo("cultmesh://aetheria/world/zone-render"));
Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest"));
Assert.That(projection.PlayableWorld.AssetProviderId, Is.EqualTo("aetheria.assets"));
Assert.That(projection.PlayableWorld.AssetVerseId, Is.EqualTo("aetheria.mods"));
Assert.That(projection.PlayableWorld.AssetAuthorityRuntimeId, Is.EqualTo("mod-authority"));
Assert.That(projection.PlayableWorld.AssetRendezvousEndpoints, Is.EqualTo(new[] { "wss://odin-a/cultmesh", "wss://odin-b/cultmesh" }));
Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person"));
Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1"));
Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty);
@ -1165,6 +1175,9 @@ 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",
@ -1186,7 +1199,153 @@ 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!.ConnectionEpoch, Is.EqualTo(2));
Assert.That(bootstrap.Host, Is.Not.SameAs(originalHost));
Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1));
Assert.That(provider.CommitNavigationCount, Is.EqualTo(1));
Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(1));
Assert.That(provider.RollbackNavigationCount, Is.Zero);
Assert.That(provider.PreviousPresentationActiveDuringCommit, Is.False);
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
"receipt:launch-again",
"aetheria.hangar.launch",
"launch",
"accepted",
"Aetheria",
"commander-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"aetheria.daemon",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3076" })));
yield return null;
yield return null;
Assert.That(provider.CommitNavigationCount, Is.EqualTo(2));
Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(2));
Assert.That(provider.ActiveConnectionLeases, Is.EqualTo(1),
"disposing superseded presentations must leave the committed Verse connected");
Assert.That(hostObject.transform.Cast<Transform>()
.Count(child => child.name == "Eve Unity Candidate Presentation"), Is.EqualTo(1));
}
finally
{
UnityEngine.Object.DestroyImmediate(hostObject);
}
}
[UnityTest]
public IEnumerator PlayableWorldClientBootstrapKeepsMountedPresentationAfterNavigationMountFailure()
{
var hostObject = new GameObject("generic-eve-navigation-rollback-client");
try
{
var provider = hostObject.AddComponent<FakePlayableWorldProviderComponent>();
provider.Set(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://aetheria/assets/manifest",
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"));
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
bootstrap.ConfigureProvider(provider);
bootstrap.Mount();
var originalHost = bootstrap.Host;
var originalRuntime = originalHost!.Runtime;
var originalRoot = bootstrap.SceneRoot;
var originalPresentation = bootstrap.LastPresentation;
provider.FailNextNavigationMount = true;
LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted"));
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
"receipt:launch-failure",
"aetheria.hangar.launch",
"launch",
"accepted",
"Aetheria",
"commander-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"broken-provider",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3999" })));
yield return null;
yield return null;
Assert.That(provider.CommitNavigationCount, Is.Zero);
Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1));
Assert.That(bootstrap.LastNavigationFailure, Is.Not.Null);
Assert.That(bootstrap.Host, Is.SameAs(originalHost));
Assert.That(bootstrap.Host!.Runtime, Is.SameAs(originalRuntime));
Assert.That(bootstrap.SceneRoot, Is.SameAs(originalRoot));
Assert.That(bootstrap.LastPresentation, Is.SameAs(originalPresentation));
Assert.That(bootstrap.Host!.Runtime, Is.Not.Null);
Assert.That(bootstrap.Host.ActiveWorld, Is.Not.Null);
Assert.That(bootstrap.Host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard"));
Assert.That(bootstrap.Host.ConnectionEpoch, Is.EqualTo(1));
}
finally
{
UnityEngine.Object.DestroyImmediate(hostObject);
}
}
[UnityTest]
public IEnumerator PlayableWorldClientBootstrapRestoresProviderRouteBeforeReactivatingPreviousView()
{
var hostObject = new GameObject("generic-eve-navigation-order-client");
try
{
var provider = hostObject.AddComponent<FakePlayableWorldProviderComponent>();
provider.Set(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://aetheria/assets/manifest",
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"));
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
bootstrap.ConfigureProvider(provider);
bootstrap.Mount();
var originalRoot = bootstrap.SceneRoot!;
provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf;
provider.FailNextNavigationFinalize = true;
LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted"));
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
"receipt:launch-finalize-failure",
"aetheria.hangar.launch",
"launch",
"accepted",
"Aetheria",
"commander-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"candidate-provider",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3999" })));
yield return null;
yield return null;
Assert.That(provider.CommitNavigationCount, Is.EqualTo(1));
Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1));
Assert.That(provider.ActiveRoute, Is.EqualTo("original"));
Assert.That(provider.PreviousPresentationActiveDuringRollback, Is.False);
Assert.That(originalRoot.gameObject.activeSelf, Is.True);
Assert.That(bootstrap.Host!.Runtime, Is.Not.Null);
}
finally
{
@ -2575,6 +2734,10 @@ namespace GameCult.Eve.UnityScene.Tests
["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render",
["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1",
["assetManifest"] = "cultmesh://aetheria/assets/manifest",
["assetProviderId"] = "aetheria.assets",
["assetVerseId"] = "aetheria.mods",
["assetAuthorityRuntimeId"] = "mod-authority",
["assetRendezvousEndpoints"] = "wss://odin-a/cultmesh;wss://odin-b/cultmesh",
["inputProfile"] = "arpg-third-person",
["cameraRig"] = cameraRig,
["cameraLookAt"] = cameraLookAt,
@ -3280,6 +3443,7 @@ namespace GameCult.Eve.UnityScene.Tests
IEveUnitySceneCommandReceiptSource,
IEveUnityProviderRefreshSource,
IEveUnityInputCapabilitySource,
IEveUnitySceneProviderSurfaceDocumentConnection,
IEveUnityNavigableProvider
{
public List<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
@ -3288,6 +3452,28 @@ 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;
@ -3309,7 +3495,9 @@ namespace GameCult.Eve.UnityScene.Tests
new EveInputCapabilityDocument();
EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument =>
CurrentSurfaceDocument;
_failSurfaceRead
? throw new InvalidOperationException("Deliberate candidate surface read failure.")
: CurrentSurfaceDocument;
public event Action<EveUnitySceneProviderSurfaceDocument>? DocumentAvailable;
@ -3353,8 +3541,47 @@ 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.105",
"version": "0.3.134",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.46",
"org.gamecult.cultlib": "1.0.56",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.3.1",
"org.gamecult.eve.unity-uitoolkit": "0.1.3",
"org.gamecult.eve.surface": "0.3.6",
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.modules.particlesystem": "1.0.0"

View file

@ -17,6 +17,7 @@ namespace GameCult.Eve.UnityUIToolkit
private Vector2 _inventoryDragStart;
private bool _inventoryPointerMoved;
private bool _suppressInventoryClick;
private readonly List<VisualElement> _inventoryPreviewCells = new();
public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null)
{
@ -45,6 +46,7 @@ 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;
@ -130,6 +132,8 @@ namespace GameCult.Eve.UnityUIToolkit
element.style.flexWrap = Wrap.Wrap;
return element;
}
case "scroll":
return new ScrollView(ScrollViewMode.Vertical);
case "pane":
case "modal":
case "card":
@ -273,6 +277,7 @@ namespace GameCult.Eve.UnityUIToolkit
return;
if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink))
_inventoryDragSource = null;
ClearInventoryPreview();
});
return grid;
}
@ -291,6 +296,7 @@ namespace GameCult.Eve.UnityUIToolkit
_inventoryDragSource = component;
_inventoryDragStart = evt.position;
_inventoryPointerMoved = false;
ClearInventoryPreview();
item.CapturePointer(evt.pointerId);
evt.StopPropagation();
});
@ -300,7 +306,15 @@ namespace GameCult.Eve.UnityUIToolkit
return;
var pointerPosition = new Vector2(evt.position.x, evt.position.y);
if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f)
{
_inventoryPointerMoved = true;
var picked = item.panel?.Pick(evt.position);
var targetElement = InventoryGridAncestor(picked);
if (targetElement?.userData is EveSurfaceComponent target)
ShowInventoryPreview(component, target, targetElement, evt.position);
else
ClearInventoryPreview();
}
});
item.RegisterCallback<PointerUpEvent>(evt =>
{
@ -315,6 +329,7 @@ namespace GameCult.Eve.UnityUIToolkit
TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink);
_inventoryDragSource = null;
_suppressInventoryClick = true;
ClearInventoryPreview();
}
evt.StopPropagation();
});
@ -406,6 +421,50 @@ namespace GameCult.Eve.UnityUIToolkit
return true;
}
private void ShowInventoryPreview(
EveSurfaceComponent source,
EveSurfaceComponent target,
VisualElement targetElement,
Vector2 panelPosition)
{
ClearInventoryPreview();
var local = targetElement.WorldToLocal(panelPosition);
var cellSize = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f));
var gap = Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f));
var pitch = cellSize + gap;
var x = Math.Max(0, (int)Math.Floor(local.x / Math.Max(1f, pitch)));
var y = Math.Max(0, (int)Math.Floor(local.y / Math.Max(1f, pitch)));
if (!EveInventoryInteraction.TryCreatePlacementPreview(source, target, x, y, out var preview) ||
preview == null)
return;
foreach (var cell in preview.Cells)
{
var element = new VisualElement { pickingMode = PickingMode.Ignore };
element.AddToClassList("eve-inventory-placement-preview");
element.AddToClassList(preview.IsValid
? "eve-inventory-placement-valid"
: "eve-inventory-placement-invalid");
element.style.position = Position.Absolute;
element.style.left = cell.X * pitch;
element.style.top = cell.Y * pitch;
element.style.width = cellSize;
element.style.height = cellSize;
element.style.backgroundColor = preview.IsValid
? new Color(0.2f, 0.9f, 0.65f, 0.34f)
: new Color(1f, 0.25f, 0.2f, 0.38f);
targetElement.Add(element);
_inventoryPreviewCells.Add(element);
}
}
private void ClearInventoryPreview()
{
foreach (var cell in _inventoryPreviewCells)
cell.RemoveFromHierarchy();
_inventoryPreviewCells.Clear();
}
private static int ParseInt(string value, int fallback) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
@ -479,18 +538,26 @@ namespace GameCult.Eve.UnityUIToolkit
})
.Where(option => !string.IsNullOrWhiteSpace(option.Value))
.ToArray();
var labels = options.Select(option => option.Label).ToList();
var duplicateLabels = options
.GroupBy(option => option.Label, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToHashSet(StringComparer.Ordinal);
var labels = options
.Select(option => duplicateLabels.Contains(option.Label)
? $"{option.Label} [{option.Value}]"
: option.Label)
.ToList();
var selectedValue = component.GetProp("value");
var selectedIndex = Array.FindIndex(options, option =>
string.Equals(option.Value, selectedValue, StringComparison.Ordinal));
var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex));
field.SetEnabled(!ParseBool(component.GetProp("disabled")));
field.RegisterValueChangedCallback(change =>
{
var option = options.FirstOrDefault(candidate =>
string.Equals(candidate.Label, change.newValue, StringComparison.Ordinal));
if (option == null)
var index = field.index;
if (index < 0 || index >= options.Length)
return;
var option = options[index];
var payload = new Dictionary<string, string>(component.Props, StringComparer.Ordinal)
{
["value"] = option.Value
@ -600,6 +667,12 @@ namespace GameCult.Eve.UnityUIToolkit
element.style.alignItems = align;
if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify))
element.style.justifyContent = justify;
if (TryGet(layout, "flexGrow", out var flexGrow))
element.style.flexGrow = ParseFloat(flexGrow);
if (TryGet(layout, "flexShrink", out var flexShrink))
element.style.flexShrink = ParseFloat(flexShrink);
if (TryGet(layout, "flexBasis", out var flexBasis))
element.style.flexBasis = ParseLength(flexBasis);
if (TryGet(layout, "width", out var width))
element.style.width = ParseLength(width);
if (TryGet(layout, "minWidth", out var minWidth))
@ -676,6 +749,14 @@ namespace GameCult.Eve.UnityUIToolkit
return false;
}
private static bool IsEnabled(EveSurfaceComponent component)
{
if (ParseBool(component.GetProp("disabled")))
return false;
var enabled = component.GetProp("enabled");
return string.IsNullOrWhiteSpace(enabled) || ParseBool(enabled);
}
private static StyleLength ParseLength(string value)
{
value = FirstToken(value);

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Eve.Surface;
using GameCult.Mesh;
using NUnit.Framework;
@ -51,6 +52,69 @@ 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()
{
@ -244,18 +308,112 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
["targetKind"] = "equipment",
["targetEntityKey"] = "zone.0.entity.1",
["targetIndex"] = "-1",
["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem"
["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem",
["payload.shipId"] = "hangar.ship.1",
["payload.expectedHangarRevision"] = "42"
});
Assert.That(EveInventoryInteraction.TryCreateDropRequest(
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.3",
"version": "0.1.9",
"unity": "2021.3",
"description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.",
"dependencies": {
"org.gamecult.eve.surface": "0.3.0"
"org.gamecult.eve.surface": "0.3.6"
},
"keywords": [
"Eve",

View file

@ -17,10 +17,10 @@ param(
)
$ErrorActionPreference = "Stop"
$expectedEveUnityCommit = "ddd2c5871f935978a0640d43cb58e18579523125"
$expectedEveUnityCommit = "d1663b36d12b60ff5c7249135eff48edf6227ca9"
$expectedEveFieldsCommit = "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
$expectedEveUnityUiToolkitCommit = "44390549775ec535ee31fc60a5bc594f372f4147"
$expectedCultLibCommit = "f67f5122ed1bd11da016e7b820ed60145ccd0299"
$expectedEveUnityUiToolkitCommit = "d1663b36d12b60ff5c7249135eff48edf6227ca9"
$expectedCultLibCommit = "334e60f1928b4212a29dd8b0d19b2c099fe6365e"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectRoot = $ClientProject
$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory }

View file

@ -1,50 +1,87 @@
param(
[string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe",
[string] $CultLibRoot = "E:\Projects\CultLib",
[string] $CultLibRoot = "",
[string] $EveRoot = "",
[string] $EvePluginsRoot = "",
[string] $NuGetConfig = "",
[switch] $SkipCultLibBuild,
[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"
$testManifestPath = Join-Path $projectPath "Packages\manifest.json"
if (Test-Path -LiteralPath $obsoleteSurfacePackage) {
$obsoleteSurfaceSource = @("package.json", "Runtime", "Editor") |
ForEach-Object { Join-Path $obsoleteSurfacePackage $_ } |
Where-Object { Test-Path -LiteralPath $_ }
if ($obsoleteSurfaceSource.Count -gt 0) {
throw "EveUnity must consume the Eve-owned surface package; duplicate source exists at $obsoleteSurfacePackage"
}
$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)) {
foreach ($required in @(
$UnityExe,
$projectPath,
$cultLibBuilder,
(Join-Path $EveRoot "packages\org.gamecult.eve.surface\package.json"),
(Join-Path $EvePluginsRoot "plugins\eve-plugin-fields\unity\org.gamecult.eve.plugin-fields\package.json")
)) {
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" }
}
powershell -ExecutionPolicy Bypass -File $cultLibBuilder
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
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" }
}
$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
$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))
$resultsPath = Join-Path $runRoot "unity-editmode-results.xml"
$logPath = Join-Path $runRoot "unity-editmode.log"
$arguments = @(
"-batchmode", "-projectPath", $projectPath,
"-batchmode", "-projectPath", $consumerPath,
"-runTests", "-testPlatform", "EditMode",
"-assemblyNames", "GameCult.Eve.UnityScene.Tests",
"-testResults", $resultsPath, "-logFile", $logPath
@ -72,5 +109,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 "Consumer: $projectPath"
Write-Host "CultLib package: $stagedCultLibPackage"
Write-Host "Isolated consumer: $consumerPath"
Write-Host "Staged dependencies: $stagedDependenciesRoot"

View file

@ -5,9 +5,16 @@ param(
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = "ReleaseConsumerProject"
$sourceProjectPath = Join-Path $repoRoot "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,
@ -15,7 +22,12 @@ $arguments = @(
"-testResults", $results, "-logFile", $logPath
)
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -WorkingDirectory $repoRoot -Wait -PassThru -WindowStyle Hidden
$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))."
}
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)"
@ -26,3 +38,4 @@ 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,9 +14,21 @@ foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) {
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity UI Toolkit test path not found: $required" }
}
powershell -ExecutionPolicy Bypass -File $cultLibBuilder
& $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"
@ -27,10 +39,16 @@ $arguments = @(
"-assemblyNames", "GameCult.Eve.UnityUIToolkit.Tests",
"-testResults", $resultsPath, "-logFile", $logPath
)
$process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -ne 0) {
$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
if (Test-Path -LiteralPath $logPath) { Get-Content -LiteralPath $logPath -Tail 120 }
throw "EveUnity UI Toolkit tests failed with exit code $($process.ExitCode)"
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"
}
[xml]$results = Get-Content -LiteralPath $resultsPath -Raw