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
Meta Vens
c6b7ecebc5 Follow provider-owned Verse navigation 2026-08-17 22:43:10 +02:00
Meta Vens
000917fa63 Verify released Eve Unity package graph 2026-08-17 21:42:33 +02:00
Meta Vens
50cd42137a Exercise Unity dropdown dispatch on a live panel 2026-08-17 21:38:30 +02:00
Meta Vens
329da07135 Require CultLib Unity 1.0.45 2026-08-17 21:27:55 +02:00
Meta Vens
754be7e515 Consume Eve-owned surface contracts 2026-08-17 21:21:13 +02:00
Meta Vens
9833d40172 Route Eve Unity through stable CultMesh identity 2026-08-17 21:02:01 +02:00
Meta Vens
7c1dd5a17d Lower typed Eve select controls in Unity 2026-08-08 16:26:00 +01:00
Meta Vens
befdd6a4a1 Give UI Toolkit commands idempotency keys 2026-08-08 09:07:55 +01:00
Meta Vens
050b2019bd Package a visible runtime UI theme 2026-07-20 05:05:04 +01:00
Meta Vens
0947a8b753 Prove released Eve cockpit lifecycle 2026-07-20 05:00:37 +01:00
73 changed files with 4228 additions and 3594 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

@ -15,7 +15,10 @@ assembly is part of the client.
`EveUnityCultMeshPlayableWorldProvider` is the shipped composition root. Its
provider, surface, and Verse filters default to empty, so a client can select by
the generic `interactive-world` contract alone.
the generic `interactive-world` contract alone. After selection, Unity retains
the rendezvous address, Verse ID, and stable provider identity only. `CultMeshClient` owns
physical route discovery, reconnection, typed live-resource leases, command
submission, and content/body sessions.
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\run-package-tests.ps1

View file

@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "";
var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "";
var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ??
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc");
var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ??
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}");
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ??
Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png");
var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ??
@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
provider = root.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
provider.Configure(
rendezvousEndpoint,
replicaPath,
cacheDirectory,
providerId,
surfaceId,
requiredSurfaceKind: "interactive-world",
@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
rendezvousEndpoint,
provider.Selection.VerseId);
provider.Selection.VerseId,
provider.Selection.AuthorityRuntimeId);
var quicDeadline = Time.realtimeSinceStartup + 15f;
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
yield return new WaitForSecondsRealtime(0.05f);
@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
private static async Task<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.43",
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
"org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
"org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4",
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.101",
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2"
"org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9",
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9"
},
"testables": [
"org.gamecult.eve.unity-scene",

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.43",
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "f67f5122ed1bd11da016e7b820ed60145ccd0299"
"hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e"
},
"org.gamecult.eve.plugin-fields": {
"version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
@ -135,37 +135,37 @@
"hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
},
"org.gamecult.eve.surface": {
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4",
"version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.cultlib": "1.0.8"
"org.gamecult.cultlib": "1.0.56"
},
"hash": "e08fa08335f99e9edddeb706912eecfad07cb281"
"hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825"
},
"org.gamecult.eve.unity-scene": {
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.101",
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.cultlib": "1.0.43",
"org.gamecult.cultlib": "1.0.56",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.2.4",
"org.gamecult.eve.unity-uitoolkit": "0.1.2",
"org.gamecult.eve.surface": "0.3.6",
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.modules.particlesystem": "1.0.0"
},
"hash": "691c75dfa9dcecbd0d46cbe11569b1622d23c7e0"
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
},
"org.gamecult.eve.unity-uitoolkit": {
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.2",
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.eve.surface": "0.2.4"
"org.gamecult.eve.surface": "0.3.6"
},
"hash": "44390549775ec535ee31fc60a5bc594f372f4147"
"hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9"
},
"com.unity.modules.assetbundle": {
"version": "1.0.0",

View file

@ -182,8 +182,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "";
var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "";
var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ??
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc");
var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ??
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}");
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ??
Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png");
var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ??
@ -258,7 +258,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
provider = root.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
provider.Configure(
rendezvousEndpoint,
replicaPath,
cacheDirectory,
providerId,
surfaceId,
requiredSurfaceKind: "interactive-world",
@ -310,7 +310,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
var quicProof = ReceiveAdvertisedRealtimeFrameAsync(
rendezvousEndpoint,
provider.Selection.VerseId);
provider.Selection.VerseId,
provider.Selection.AuthorityRuntimeId);
var quicDeadline = Time.realtimeSinceStartup + 15f;
while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline)
yield return new WaitForSecondsRealtime(0.05f);
@ -1200,7 +1201,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
private static async Task<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

@ -7,7 +7,7 @@
"com.unity.modules.uielements": "1.0.0",
"org.gamecult.cultlib": "file:../../artifacts/test-dependencies/org.gamecult.cultlib",
"org.gamecult.eve.plugin-fields": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields",
"org.gamecult.eve.surface": "file:../../packages/org.gamecult.eve.surface",
"org.gamecult.eve.surface": "file:../../../Eve/packages/org.gamecult.eve.surface",
"org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene",
"org.gamecult.eve.unity-uitoolkit": "file:../../packages/org.gamecult.eve.unity-uitoolkit"
},

View file

@ -133,11 +133,11 @@
}
},
"org.gamecult.eve.surface": {
"version": "file:../../packages/org.gamecult.eve.surface",
"version": "file:../../../Eve/packages/org.gamecult.eve.surface",
"depth": 0,
"source": "local",
"dependencies": {
"org.gamecult.cultlib": "1.0.8"
"org.gamecult.cultlib": "1.0.56"
}
},
"org.gamecult.eve.unity-scene": {
@ -145,9 +145,10 @@
"depth": 0,
"source": "local",
"dependencies": {
"org.gamecult.cultlib": "1.0.14",
"org.gamecult.cultlib": "1.0.56",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.2.2",
"org.gamecult.eve.surface": "0.3.6",
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.modules.particlesystem": "1.0.0"
@ -158,7 +159,7 @@
"depth": 0,
"source": "local",
"dependencies": {
"org.gamecult.eve.surface": "0.1.0"
"org.gamecult.eve.surface": "0.3.6"
}
},
"com.unity.modules.assetbundle": {

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

@ -9,6 +9,9 @@ importing provider product code into the generic runtime.
EveUnity owns Unity scene projection, `GameObject` lifecycle, camera and input
drivers, asset-provider hooks, package release, runtime tests, and captures.
`CultMeshClient` owns physical route selection, reconnection, shared typed
resource sessions, and their lease lifetime. Unity receives stable Verse and
provider identities and cannot preserve a physical route as gameplay authority.
## Inputs
@ -40,6 +43,10 @@ presentation counters, and receipt display state are projections. They are not
world truth. Temporal targets reset when the volume program, node, or render size
changes and can never become provider state.
The optional Unity cache directory contains verified content-transfer state and
mapped body bytes only. It is disposable derived data, not a CultCache replica
of provider gameplay documents.
Camera-relative field viewports have one generic resolver. The advertised
viewport frame owns its extent, spatial-cell scale, raster texels per cell, and
snap policy; fog volumes and stateless particle programs consume the identical
@ -65,6 +72,18 @@ temporal pass must also advertise its current-sample, history,
previous-view-projection, and reset-history ports; partial temporal programs fail
closed.
Unity code may not open provider snapshot sessions, schema clients, write
forwarders, subscriptions, or replica shards against a discovered physical
route. Reads, watches, content/body transfer, realtime frames, and command
submissions share the same stable-identity `CultMeshClient` owner.
Remote bootstrap and asset materialization complete through `PrepareAsync`
before the scene is mounted. `Connect`, `Mount`, input, refresh, and disposal
cannot synchronously wait on CultMesh. A bounded command outbox owns delivery;
continuous movement/look values may be replaced by a newer value, but transport
send completion cannot retire any command. Only the provider's canonical
receipt retires its idempotency key. Unacknowledged work is retried fairly; it
cannot become client-authored success or starve unrelated commands.
Viewport-sized shader inputs are likewise presentation state. A provider may
relate a logical vector port to a logical native texture port through
`viewportTextureScaleBindings`; EveUnity derives the vector from the active
@ -86,11 +105,14 @@ modes into one camera opinion or infer a product camera from entity kind.
## Shared Paths
Initial connect discovers a Verse endpoint and selects a provider surface by
semantic kind. Refresh, reconnect, and terminal-receipt reconciliation consume
that provider's surface documents. Movement, focus, target, and action input all
become `gamecult.eve.command_invocation.v1` through the provider-advertised
command boundary.
Initial preparation asks one rendezvous for Verses, selects an advertised
authority runtime and provider surface by semantic kind, then leases it through
the explicit Verse/authority-runtime target. The Eve provider ID remains a
separate surface-owner identity.
Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain
inside the same identity-owned session. Movement, focus, target, and action
input all become `gamecult.eve.command_invocation.v1` through the
provider-advertised command boundary.
## Cut Line

View file

@ -1,7 +0,0 @@
<Project>
<PropertyGroup>
<BaseOutputPath>$(MSBuildThisFileDirectory)bin~\</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)obj~\</BaseIntermediateOutputPath>
<MSBuildProjectExtensionsPath>$(MSBuildThisFileDirectory)obj~\</MSBuildProjectExtensionsPath>
</PropertyGroup>
</Project>

View file

@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Description>Portable Eve surface and provider contracts.</Description>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
</ItemGroup>
</Project>

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: ce2d4913d64690c44ae68935014fd79d
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,29 +0,0 @@
# Eve Surface Contract
This package defines the retained CultUI surface document that every Eve
runtime lowers.
Components own normal child layout through `Children`, live CultMesh value
bindings through `StateBindings`, and nested synced document regions through
`EmbeddedDocuments`. An embedded document slot names the child document id,
schema, presentation kind, and route hint; renderers resolve that child through
CultMesh instead of rebuilding it with runtime-local projection code.
See `../../docs/surface-contract-v1.md` for the full contract and
`../../web/fixtures/cultui-embedded-surface.json` for the parity fixture.
Runtime discovery and required feature coverage live in
`../../tools/parity/parity-manifest.json`; active GUI runtimes must list
`embeddedDocuments` as a supported feature and require the `embedded-surface`
fixture.
Verification entrypoints:
- Browser/TypeScript: `node --test web\eve-dsl.test.mjs`
- Semantic runtime matrix: `powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1`
- Flutter GUI contract, when Flutter is installed: `flutter test --plain-name embedded_surface_fixture_contract`
- Rust/CultMesh document sync: run `cargo test rust_preserves_cultui_embedded_surface_slots_through_typed_document_sync` from `E:/Projects/CultLib/packages/cultnet-rs`
The parity manifest is the discovery source for web, Flutter desktop/Linux/Android,
iOS/UIKit, Android/Kotlin, Unity UI Toolkit, and Rust contract coverage. Do not
add a new renderer claim without adding its nested-surface fixture requirement
there.

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: fe5aa27636bead047b2aecb14fdc59f2
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 2164f6fea1216214d895e7dc99c46ecf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,144 +0,0 @@
using System;
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
#nullable enable
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.asset_catalog", SchemaId)]
[MessagePackObject]
public sealed class EveAssetCatalogDocument
{
public const string SchemaId = "gamecult.eve.asset_catalog.v1";
[SerializationConstructor]
public EveAssetCatalogDocument(
string schema,
string providerId,
string catalogId,
long version,
string updatedAtUtc,
EveAssetCatalogEntry[] assets)
{
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
ProviderId = providerId ?? "";
CatalogId = catalogId ?? "";
Version = version;
UpdatedAtUtc = updatedAtUtc ?? "";
Assets = assets ?? Array.Empty<EveAssetCatalogEntry>();
}
public EveAssetCatalogDocument(
string providerId,
string catalogId,
long version,
string updatedAtUtc,
EveAssetCatalogEntry[] assets)
: this(SchemaId, providerId, catalogId, version, updatedAtUtc, assets)
{
}
[Key(0)] public string Schema { get; }
[Key(1)] public string ProviderId { get; }
[Key(2)] public string CatalogId { get; }
[Key(3)] public long Version { get; }
[Key(4)] public string UpdatedAtUtc { get; }
[Key(5)] public EveAssetCatalogEntry[] Assets { get; }
}
[MessagePackObject]
public sealed class EveAssetCatalogEntry
{
[SerializationConstructor]
public EveAssetCatalogEntry(
string assetRef,
string assetKind,
EveAssetVariant[] variants,
Dictionary<string, string> metadata)
{
AssetRef = assetRef ?? "";
AssetKind = assetKind ?? "";
Variants = variants ?? Array.Empty<EveAssetVariant>();
Metadata = metadata == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(metadata, StringComparer.Ordinal);
}
public EveAssetCatalogEntry(
string assetRef,
string assetKind,
EveAssetVariant[] variants,
IReadOnlyDictionary<string, string>? metadata = null)
: this(
assetRef,
assetKind,
variants,
metadata == null ? new Dictionary<string, string>() : new Dictionary<string, string>(metadata))
{
}
[Key(0)] public string AssetRef { get; }
[Key(1)] public string AssetKind { get; }
[Key(2)] public EveAssetVariant[] Variants { get; }
[Key(3)] public Dictionary<string, string> Metadata { get; }
}
[MessagePackObject]
public sealed class EveAssetVariant
{
[SerializationConstructor]
public EveAssetVariant(
string runtimeId,
string platform,
string format,
string uri,
string contentHash,
long sizeBytes,
string assetKey,
Dictionary<string, string> metadata)
{
RuntimeId = runtimeId ?? "";
Platform = platform ?? "";
Format = format ?? "";
Uri = uri ?? "";
ContentHash = contentHash ?? "";
SizeBytes = sizeBytes;
AssetKey = assetKey ?? "";
Metadata = metadata == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(metadata, StringComparer.Ordinal);
}
public EveAssetVariant(
string runtimeId,
string platform,
string format,
string uri,
string contentHash,
long sizeBytes,
string assetKey,
IReadOnlyDictionary<string, string>? metadata = null)
: this(
runtimeId,
platform,
format,
uri,
contentHash,
sizeBytes,
assetKey,
metadata == null ? new Dictionary<string, string>() : new Dictionary<string, string>(metadata))
{
}
[Key(0)] public string RuntimeId { get; }
[Key(1)] public string Platform { get; }
[Key(2)] public string Format { get; }
[Key(3)] public string Uri { get; }
[Key(4)] public string ContentHash { get; }
[Key(5)] public long SizeBytes { get; }
[Key(6)] public string AssetKey { get; }
[Key(7)] public Dictionary<string, string> Metadata { get; }
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8f53654f7c98d894588127ba001f11b4

View file

@ -1,96 +0,0 @@
using System;
using GameCult.Caching;
using MessagePack;
#nullable enable
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.entity_soa_view", SchemaId)]
[MessagePackObject]
public sealed class EveEntitySoaViewDocument
{
public const string SchemaId = "gamecult.eve.entity_soa_view.v2";
[Key(0)] public string Schema { get; set; } = SchemaId;
[Key(1)] public string ProviderId { get; set; } = "";
[Key(2)] public string ViewId { get; set; } = "";
[Key(3)] public string PublishedAtUtc { get; set; } = "";
[Key(4)] public string BodySchemaId { get; set; } = "";
[Key(5)] public int LayoutVersion { get; set; }
[Key(6)] public long ProducerEpoch { get; set; }
[Key(7)] public long Sequence { get; set; }
[Key(8)] public int Capacity { get; set; }
[Key(9)] public EveEntitySoaBuffer[] Buffers { get; set; } = Array.Empty<EveEntitySoaBuffer>();
[Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty<EveEntitySoaColumn>();
[Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty<EveEntitySoaDirtyRange>();
[Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty<EveEntityRenderGroup>();
[Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty<EveEntityIdentity>();
[Key(14)] public long FrameId { get; set; }
}
[MessagePackObject]
public sealed class EveEntitySoaBuffer
{
[Key(0)] public string BufferId { get; set; } = "";
[Key(1)] public long ByteOffset { get; set; }
[Key(2)] public long ByteLength { get; set; }
}
[MessagePackObject]
public sealed class EveEntitySoaColumn
{
[Key(0)] public string ColumnId { get; set; } = "";
[Key(1)] public string Semantic { get; set; } = "";
[Key(2)] public string BufferId { get; set; } = "";
[Key(3)] public string ScalarType { get; set; } = "";
[Key(4)] public long ByteOffset { get; set; }
[Key(5)] public int ElementStride { get; set; }
[Key(6)] public int ElementCount { get; set; }
[Key(7)] public string Unit { get; set; } = "";
[Key(8)] public string CoordinateSpace { get; set; } = "";
}
[MessagePackObject]
public sealed class EveEntitySoaDirtyRange
{
[Key(0)] public string ColumnId { get; set; } = "";
[Key(1)] public int StartIndex { get; set; }
[Key(2)] public int Count { get; set; }
[Key(3)] public long Sequence { get; set; }
}
[MessagePackObject]
public sealed class EveEntityIdentity
{
[Key(0)] public int Index { get; set; }
[Key(1)] public string EntityId { get; set; } = "";
[Key(2)] public string EntityKind { get; set; } = "";
[Key(3)] public string Label { get; set; } = "";
[Key(4)] public string Faction { get; set; } = "";
[Key(5)] public bool Selectable { get; set; }
[Key(6)] public bool Controllable { get; set; }
[Key(7)] public string AssetRef { get; set; } = "";
}
[MessagePackObject]
public sealed class EveEntityRenderGroup
{
[Key(0)] public int GroupId { get; set; }
[Key(1)] public string MeshAssetRef { get; set; } = "";
[Key(2)] public string MaterialAssetRef { get; set; } = "";
[Key(3)] public int SubMeshIndex { get; set; }
[Key(4)] public int Layer { get; set; }
[Key(5)] public int InstanceCount { get; set; }
[Key(6)] public float DefaultScale { get; set; } = 1f;
[Key(7)] public float BoundsCenterX { get; set; }
[Key(8)] public float BoundsCenterY { get; set; }
[Key(9)] public float BoundsCenterZ { get; set; }
[Key(10)] public float BoundsSizeX { get; set; }
[Key(11)] public float BoundsSizeY { get; set; }
[Key(12)] public float BoundsSizeZ { get; set; }
[Key(13)] public string ShadowMode { get; set; } = "on";
[Key(14)] public bool ReceiveShadows { get; set; } = true;
[Key(15)] public int Lod { get; set; } = -1;
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 8e87e1be783846508efea45787f2d45c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,75 +0,0 @@
using System;
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.input_capability", SchemaId)]
[MessagePackObject]
public sealed class EveInputCapabilityDocument
{
public const string SchemaId = "gamecult.eve.input_capability.v1";
[Key(0)] public string Schema { get; set; } = SchemaId;
[Key(1)] public string ProviderId { get; set; } = "";
[Key(2)] public string CapabilityId { get; set; } = "";
[Key(3)] public long Version { get; set; }
[Key(4)] public EveInputActionDocument[] Actions { get; set; } = Array.Empty<EveInputActionDocument>();
[Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty<EveInputProfileDocument>();
}
[MessagePackObject]
public sealed class EveInputActionDocument
{
[Key(0)] public string ActionId { get; set; } = "";
[Key(1)] public string Label { get; set; } = "";
[Key(2)] public string Operation { get; set; } = "";
[Key(3)] public string Context { get; set; } = "pilot";
[Key(4)] public string Category { get; set; } = "ship";
[Key(5)] public string Availability { get; set; } = "available";
[Key(6)] public string SourceRef { get; set; } = "";
[Key(7)] public Dictionary<string, string> Payload { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
[Key(8)] public EveInputValueDocument? InputValue { get; set; }
[Key(9)] public string IconRef { get; set; } = "";
[Key(10)] public bool ActionBar { get; set; }
}
[MessagePackObject]
public sealed class EveInputValueDocument
{
[Key(0)] public string Model { get; set; } = "";
[Key(1)] public string PayloadKey { get; set; } = "";
[Key(2)] public string[] PayloadKeys { get; set; } = Array.Empty<string>();
[Key(3)] public double? CurrentValue { get; set; }
[Key(4)] public double? MinimumValue { get; set; }
[Key(5)] public double? MaximumValue { get; set; }
[Key(6)] public double? StepValue { get; set; }
[Key(7)] public string Unit { get; set; } = "";
}
[MessagePackObject]
public sealed class EveInputProfileDocument
{
[Key(0)] public string ProfileId { get; set; } = "";
[Key(1)] public string DeviceClass { get; set; } = "";
[Key(2)] public EveInputBindingDocument[] Bindings { get; set; } = Array.Empty<EveInputBindingDocument>();
}
[MessagePackObject]
public sealed class EveInputBindingDocument
{
[Key(0)] public string BindingId { get; set; } = "";
[Key(1)] public string ActionId { get; set; } = "";
[Key(2)] public EveInputGestureDocument Gesture { get; set; } = new EveInputGestureDocument();
[Key(3)] public bool ActionBar { get; set; }
}
[MessagePackObject]
public sealed class EveInputGestureDocument
{
[Key(0)] public string Kind { get; set; } = "direct";
[Key(1)] public string[] Controls { get; set; } = Array.Empty<string>();
[Key(2)] public int MaxStepIntervalMs { get; set; } = 650;
[Key(3)] public string CompletionControl { get; set; } = "";
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4c297c7dbb64a0f49a7f805e770c5e55

View file

@ -1,55 +0,0 @@
using System;
using System.Collections.Generic;
namespace GameCult.Eve.Surface
{
public sealed class EveInputDeviceLayout
{
public string LayoutId { get; set; } = "";
public string DeviceClass { get; set; } = "";
public IReadOnlyList<EveInputLayoutControl> Controls { get; set; } = Array.Empty<EveInputLayoutControl>();
public bool SupportsDirectionalStrings { get; set; }
public static EveInputDeviceLayout AnsiKeyboardMouse() => new EveInputDeviceLayout
{
LayoutId = "keyboard.ansi-104+mouse.standard",
DeviceClass = "keyboard-mouse",
Controls = new[]
{
Key("keyboard.escape", "Esc", 0, 0, 1.25f), Key("keyboard.f1", "F1", 2, 0), Key("keyboard.f2", "F2", 3, 0),
Key("keyboard.1", "1", 1, 1), Key("keyboard.2", "2", 2, 1), Key("keyboard.3", "3", 3, 1), Key("keyboard.4", "4", 4, 1), Key("keyboard.5", "5", 5, 1),
Key("keyboard.tab", "Tab", 0, 2, 1.5f), Key("keyboard.q", "Q", 1.5f, 2), Key("keyboard.w", "W", 2.5f, 2), Key("keyboard.e", "E", 3.5f, 2), Key("keyboard.r", "R", 4.5f, 2),
Key("keyboard.a", "A", 1.75f, 3), Key("keyboard.s", "S", 2.75f, 3), Key("keyboard.d", "D", 3.75f, 3), Key("keyboard.f", "F", 4.75f, 3),
Key("keyboard.leftShift", "Shift", 0, 4, 2.25f), Key("keyboard.space", "Space", 3.75f, 5, 6.25f),
Key("mouse.primary", "Mouse 1", 18, 1), Key("mouse.secondary", "Mouse 2", 19, 1), Key("mouse.middle", "Mouse 3", 18.5f, 2)
}
};
public static EveInputDeviceLayout StandardGamepad() => new EveInputDeviceLayout
{
LayoutId = "gamepad.standard",
DeviceClass = "gamepad",
SupportsDirectionalStrings = true,
Controls = new[]
{
Key("gamepad.leftStick", "Left Stick", 2, 3, 2), Key("gamepad.rightStick", "Right Stick", 8, 4, 2),
Key("gamepad.dpad.up", "Up", 1, 1), Key("gamepad.dpad.left", "Left", 0, 2), Key("gamepad.dpad.right", "Right", 2, 2), Key("gamepad.dpad.down", "Down", 1, 3),
Key("gamepad.buttonSouth", "A / Cross", 10, 3), Key("gamepad.buttonEast", "B / Circle", 11, 2), Key("gamepad.buttonWest", "X / Square", 9, 2), Key("gamepad.buttonNorth", "Y / Triangle", 10, 1),
Key("gamepad.leftTrigger", "LT / L2", 2, 0, 2), Key("gamepad.rightTrigger", "RT / R2", 9, 0, 2)
}
};
private static EveInputLayoutControl Key(string id, string label, float x, float y, float width = 1) =>
new EveInputLayoutControl { Control = id, Label = label, X = x, Y = y, Width = width, Height = 1 };
}
public sealed class EveInputLayoutControl
{
public string Control { get; set; } = "";
public string Label { get; set; } = "";
public float X { get; set; }
public float Y { get; set; }
public float Width { get; set; } = 1;
public float Height { get; set; } = 1;
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8a2a1258551e42a9831a3e4f9483d928

View file

@ -1,82 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace GameCult.Eve.Surface
{
public sealed class EveInputGesture
{
public string Kind { get; set; } = "direct";
public IReadOnlyList<string> Controls { get; set; } = Array.Empty<string>();
public int MaxStepIntervalMs { get; set; } = 650;
public string CompletionControl { get; set; } = "";
}
public sealed class EveInputBinding
{
public string BindingId { get; set; } = "";
public string ActionId { get; set; } = "";
public EveInputGesture Gesture { get; set; } = new EveInputGesture();
public bool ActionBar { get; set; }
}
public sealed class EveInputGestureResolver
{
private readonly IReadOnlyList<EveInputBinding> _bindings;
private readonly HashSet<string> _pressed = new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, SequenceState> _sequences = new Dictionary<string, SequenceState>(StringComparer.Ordinal);
public EveInputGestureResolver(IEnumerable<EveInputBinding> bindings)
{
_bindings = (bindings ?? Array.Empty<EveInputBinding>()).Where(binding => binding != null).ToArray();
}
public IReadOnlyList<string> Press(string control, long timestampMs)
{
_pressed.Add(control ?? "");
var actions = new HashSet<string>(StringComparer.Ordinal);
foreach (var binding in _bindings)
{
var gesture = binding.Gesture ?? new EveInputGesture();
if (gesture.Kind == "direct" && gesture.Controls.FirstOrDefault() == control)
actions.Add(binding.ActionId);
else if (gesture.Kind == "chord" && gesture.Controls.Count > 0 && gesture.Controls.All(_pressed.Contains))
actions.Add(binding.ActionId);
else if (gesture.Kind == "sequence" && Advance(binding, control ?? "", timestampMs))
actions.Add(binding.ActionId);
}
return actions.ToArray();
}
public void Release(string control) => _pressed.Remove(control ?? "");
public void Reset()
{
_pressed.Clear();
_sequences.Clear();
}
private bool Advance(EveInputBinding binding, string control, long timestampMs)
{
var controls = binding.Gesture.Controls;
if (controls.Count == 0) return false;
_sequences.TryGetValue(binding.BindingId, out var previous);
var index = previous.Index > 0 && timestampMs - previous.TimestampMs <= Math.Max(50, binding.Gesture.MaxStepIntervalMs) ? previous.Index : 0;
var next = controls[index] == control ? index + 1 : controls[0] == control ? 1 : 0;
if (next == controls.Count)
{
_sequences.Remove(binding.BindingId);
return string.IsNullOrWhiteSpace(binding.Gesture.CompletionControl);
}
_sequences[binding.BindingId] = new SequenceState(next, timestampMs);
return false;
}
private readonly struct SequenceState
{
public SequenceState(int index, long timestampMs) { Index = index; TimestampMs = timestampMs; }
public int Index { get; }
public long TimestampMs { get; }
}
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e3c2941be243a754092ac2543d1e6bf7

View file

@ -1,82 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using GameCult.Mesh;
#nullable enable
namespace GameCult.Eve.Surface
{
public static class EveInventoryInteraction
{
public const string GridKind = "inventory.grid";
public const string ItemKind = "inventory.item";
public const string DragSessionKind = "inventory.drag_session";
public static bool TryCreateDropRequest(
EveSurfaceDocument document,
EveSurfaceComponent source,
EveSurfaceComponent target,
int destinationX,
int destinationY,
string clientId,
out EveSurfaceCommandRequest? request)
{
request = null;
if (document == null || source == null || target == null ||
!string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) ||
!string.Equals(target.Kind, GridKind, StringComparison.Ordinal))
{
return false;
}
var sourceKind = source.GetProp("sourceKind", source.GetProp("source"));
var command = target.GetProp($"dropCommand.{sourceKind}", target.GetProp("dropCommand"));
if (string.IsNullOrWhiteSpace(sourceKind) || string.IsNullOrWhiteSpace(command))
return false;
var sourceIndex = Int(source.GetProp("sourceIndex"), -1);
var targetIndex = Int(target.GetProp("targetIndex"), -1);
var targetKind = target.GetProp("targetKind");
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
["sourceKind"] = sourceKind,
["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")),
["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture),
["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1)
.ToString(CultureInfo.InvariantCulture),
["itemKey"] = source.GetProp("itemKey"),
["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture),
["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture),
["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture),
["destinationKind"] = targetKind,
["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")),
["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture),
["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1)
.ToString(CultureInfo.InvariantCulture),
["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture),
["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture),
["hasDestinationPosition"] = "true"
};
var template = document.Commands.FirstOrDefault(candidate =>
string.Equals(candidate.Command, command, StringComparison.Ordinal));
var operation = template == null
? CultMesh.OperationInvocation(command)
: CultMesh.OperationInvocation(template.Operation);
request = new EveSurfaceCommandRequest(
document.ProviderId,
document.Surface.Id,
operation,
CultMesh.OperationPayload(payload),
DateTimeOffset.UtcNow,
string.IsNullOrWhiteSpace(clientId) ? "unity-uitoolkit" : clientId);
return true;
}
private static int Int(string value, int fallback) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: fallback;
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 17fd4173e6e44b4b9c503d343021ffcb

View file

@ -1,414 +0,0 @@
using System;
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
using MessagePack.Formatters;
#nullable enable
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.provider_advertisement", SchemaId)]
[MessagePackObject]
[MessagePackFormatter(typeof(EveProviderAdvertisementCompatibilityFormatter))]
public sealed class EveProviderAdvertisementDocument
{
public const string SchemaId = "gamecult.eve.provider_advertisement.v1";
public EveProviderAdvertisementDocument(
string providerId,
string serviceId,
string verseId,
string title,
string kind,
string cultMeshAddress,
string updatedAtUtc,
EveProviderFreshness freshness,
IReadOnlyList<string> schemas,
IReadOnlyList<EveProviderWitness> witnesses,
IReadOnlyList<EveAdvertisedSurface> surfaces,
IReadOnlyList<EveAdvertisedCommand> commands,
IReadOnlyList<string>? authorizedBodyProducerIds = null)
: this(SchemaId, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands, authorizedBodyProducerIds)
{
}
[SerializationConstructor]
public EveProviderAdvertisementDocument(
string schema,
string providerId,
string serviceId,
string verseId,
string title,
string kind,
string cultMeshAddress,
string updatedAtUtc,
EveProviderFreshness freshness,
IReadOnlyList<string> schemas,
IReadOnlyList<EveProviderWitness> witnesses,
IReadOnlyList<EveAdvertisedSurface> surfaces,
IReadOnlyList<EveAdvertisedCommand> commands,
IReadOnlyList<string>? authorizedBodyProducerIds = null)
{
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
ProviderId = providerId ?? "";
ServiceId = serviceId ?? "";
VerseId = verseId ?? "";
Title = title ?? "";
Kind = kind ?? "";
CultMeshAddress = cultMeshAddress ?? "";
UpdatedAtUtc = updatedAtUtc ?? "";
Freshness = freshness ?? new EveProviderFreshness("unknown", "", 0);
Schemas = schemas ?? Array.Empty<string>();
Witnesses = witnesses ?? Array.Empty<EveProviderWitness>();
Surfaces = surfaces ?? Array.Empty<EveAdvertisedSurface>();
Commands = commands ?? Array.Empty<EveAdvertisedCommand>();
AuthorizedBodyProducerIds = authorizedBodyProducerIds ?? Array.Empty<string>();
}
[Key(0)] public string Schema { get; }
[Key(1)] public string ProviderId { get; }
[Key(2)] public string ServiceId { get; }
[Key(3)] public string VerseId { get; }
[Key(4)] public string Title { get; }
[Key(5)] public string Kind { get; }
[Key(6)] public string CultMeshAddress { get; }
[Key(7)] public string UpdatedAtUtc { get; }
[Key(8)] public EveProviderFreshness Freshness { get; }
[Key(9)] public IReadOnlyList<string> Schemas { get; }
[Key(10)] public IReadOnlyList<EveProviderWitness> Witnesses { get; }
[Key(11)] public IReadOnlyList<EveAdvertisedSurface> Surfaces { get; }
[Key(12)] public IReadOnlyList<EveAdvertisedCommand> Commands { get; }
[Key(13)] public IReadOnlyList<string> AuthorizedBodyProducerIds { get; }
}
/// <summary>
/// Reads canonical provider advertisements and the original Aetheria-expanded v1 layout.
/// </summary>
public sealed class EveProviderAdvertisementCompatibilityFormatter :
IMessagePackFormatter<EveProviderAdvertisementDocument?>
{
private const int CurrentFieldCount = 14;
private const int LegacyFieldCount = 16;
public void Serialize(
ref MessagePackWriter writer,
EveProviderAdvertisementDocument? value,
MessagePackSerializerOptions options)
{
if (value == null) { writer.WriteNil(); return; }
writer.WriteArrayHeader(CurrentFieldCount);
writer.Write(value.Schema);
writer.Write(value.ProviderId);
writer.Write(value.ServiceId);
writer.Write(value.VerseId);
writer.Write(value.Title);
writer.Write(value.Kind);
writer.Write(value.CultMeshAddress);
writer.Write(value.UpdatedAtUtc);
Formatter<EveProviderFreshness>(options).Serialize(ref writer, value.Freshness, options);
Formatter<IReadOnlyList<string>>(options).Serialize(ref writer, value.Schemas, options);
Formatter<IReadOnlyList<EveProviderWitness>>(options).Serialize(ref writer, value.Witnesses, options);
Formatter<IReadOnlyList<EveAdvertisedSurface>>(options).Serialize(ref writer, value.Surfaces, options);
Formatter<IReadOnlyList<EveAdvertisedCommand>>(options).Serialize(ref writer, value.Commands, options);
Formatter<IReadOnlyList<string>>(options).Serialize(ref writer, value.AuthorizedBodyProducerIds, options);
}
public EveProviderAdvertisementDocument? Deserialize(
ref MessagePackReader reader,
MessagePackSerializerOptions options)
{
if (reader.TryReadNil()) return null;
if (reader.NextMessagePackType != MessagePackType.Array)
throw new MessagePackSerializationException("Eve provider advertisement must be an array.");
options.Security.DepthStep(ref reader);
try
{
var fields = reader.ReadArrayHeader();
if (fields == LegacyFieldCount)
return ReadLegacy(ref reader, options);
var schema = ReadString(ref reader, fields, 0);
var providerId = ReadString(ref reader, fields, 1);
var serviceId = ReadString(ref reader, fields, 2);
var verseId = ReadString(ref reader, fields, 3);
var title = ReadString(ref reader, fields, 4);
var kind = ReadString(ref reader, fields, 5);
var address = ReadString(ref reader, fields, 6);
var updatedAtUtc = ReadString(ref reader, fields, 7);
var freshness = fields > 8 ? Formatter<EveProviderFreshness>(options).Deserialize(ref reader, options) : null;
var schemas = fields > 9 ? Formatter<IReadOnlyList<string>>(options).Deserialize(ref reader, options) : null;
var witnesses = fields > 10 ? Formatter<IReadOnlyList<EveProviderWitness>>(options).Deserialize(ref reader, options) : null;
var surfaces = fields > 11 ? Formatter<IReadOnlyList<EveAdvertisedSurface>>(options).Deserialize(ref reader, options) : null;
var commands = fields > 12 ? Formatter<IReadOnlyList<EveAdvertisedCommand>>(options).Deserialize(ref reader, options) : null;
var authorizedBodyProducerIds = fields > 13 ? Formatter<IReadOnlyList<string>>(options).Deserialize(ref reader, options) : null;
for (var index = CurrentFieldCount; index < fields; index++) reader.Skip();
return new EveProviderAdvertisementDocument(
schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc,
freshness ?? new EveProviderFreshness("unknown", "", 0),
schemas ?? Array.Empty<string>(),
witnesses ?? Array.Empty<EveProviderWitness>(),
surfaces ?? Array.Empty<EveAdvertisedSurface>(),
commands ?? Array.Empty<EveAdvertisedCommand>(),
authorizedBodyProducerIds ?? Array.Empty<string>());
}
finally { reader.Depth--; }
}
private static EveProviderAdvertisementDocument ReadLegacy(
ref MessagePackReader reader,
MessagePackSerializerOptions options)
{
var schema = ReadString(ref reader);
var providerId = ReadString(ref reader);
var serviceId = ReadString(ref reader);
var verseId = ReadString(ref reader);
reader.Skip(); // RootVerse remains provider-owned settings, not portable advertisement state.
reader.Skip(); // CanonicalService
reader.Skip(); // LocatedService
var address = ReadString(ref reader);
var title = ReadString(ref reader);
var kind = ReadString(ref reader);
var updatedAtUtc = ReadString(ref reader);
var freshness = Formatter<EveProviderFreshness>(options).Deserialize(ref reader, options)
?? new EveProviderFreshness("unknown", "", 0);
var schemas = Formatter<IReadOnlyList<string>>(options).Deserialize(ref reader, options)
?? Array.Empty<string>();
var witnesses = Formatter<IReadOnlyList<EveProviderWitness>>(options).Deserialize(ref reader, options)
?? Array.Empty<EveProviderWitness>();
var surfaces = ReadLegacySurfaces(ref reader);
var commands = ReadLegacyCommands(ref reader);
return new EveProviderAdvertisementDocument(
schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc,
freshness, schemas, witnesses, surfaces, commands);
}
private static EveAdvertisedSurface[] ReadLegacySurfaces(ref MessagePackReader reader)
{
var count = reader.ReadArrayHeader();
var values = new EveAdvertisedSurface[count];
for (var index = 0; index < count; index++)
{
var fields = reader.ReadArrayHeader();
var schema = ReadString(ref reader, fields, 0);
var surfaceId = ReadString(ref reader, fields, 1);
var recordRef = ReadString(ref reader, fields, 2);
var transport = ReadString(ref reader, fields, 3);
var status = ReadString(ref reader, fields, 4);
for (var field = 5; field < fields; field++) reader.Skip();
values[index] = new EveAdvertisedSurface(
surfaceId, schema, recordRef, transport, status, "", null);
}
return values;
}
private static EveAdvertisedCommand[] ReadLegacyCommands(ref MessagePackReader reader)
{
var count = reader.ReadArrayHeader();
var values = new EveAdvertisedCommand[count];
for (var index = 0; index < count; index++)
{
var fields = reader.ReadArrayHeader();
var command = ReadString(ref reader, fields, 0);
var transport = ReadString(ref reader, fields, 1);
var summary = ReadString(ref reader, fields, 2);
for (var field = 3; field < fields; field++) reader.Skip();
values[index] = new EveAdvertisedCommand(command, "", transport, summary);
}
return values;
}
private static IMessagePackFormatter<T> Formatter<T>(MessagePackSerializerOptions options) =>
options.Resolver.GetFormatter<T>()
?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}.");
private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? "";
private static string ReadString(ref MessagePackReader reader, int fields, int index) =>
index < fields ? ReadString(ref reader) : "";
}
[MessagePackObject]
public sealed class EveProviderFreshness
{
[SerializationConstructor]
public EveProviderFreshness(string state, string lastSeenAtUtc, long maxAgeMs)
{
State = state ?? "";
LastSeenAtUtc = lastSeenAtUtc ?? "";
MaxAgeMs = maxAgeMs;
}
[Key(0)] public string State { get; }
[Key(1)] public string LastSeenAtUtc { get; }
[Key(2)] public long MaxAgeMs { get; }
}
[MessagePackObject]
public sealed class EveProviderWitness
{
[SerializationConstructor]
public EveProviderWitness(string kind, string reference, string summary)
{
Kind = kind ?? "";
Reference = reference ?? "";
Summary = summary ?? "";
}
[Key(0)] public string Kind { get; }
[Key(1)] public string Reference { get; }
[Key(2)] public string Summary { get; }
}
[MessagePackObject]
public sealed class EveAdvertisedSurface
{
[SerializationConstructor]
public EveAdvertisedSurface(
string surfaceId,
string schema,
string recordRef,
string transport,
string status,
string surfaceKind,
EveWorldInteractionAdvertisement? worldInteraction = null)
{
SurfaceId = surfaceId ?? "";
Schema = schema ?? "";
RecordRef = recordRef ?? "";
Transport = transport ?? "";
Status = status ?? "";
SurfaceKind = surfaceKind ?? "";
WorldInteraction = worldInteraction;
}
[Key(0)] public string SurfaceId { get; }
[Key(1)] public string Schema { get; }
[Key(2)] public string RecordRef { get; }
[Key(3)] public string Transport { get; }
[Key(4)] public string Status { get; }
[Key(5)] public string SurfaceKind { get; }
[Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; }
}
[MessagePackObject]
public sealed class EveWorldInteractionAdvertisement
{
[SerializationConstructor]
public EveWorldInteractionAdvertisement(
string projectionKind,
IReadOnlyList<string> stateSchemas,
string commandBoundary,
string commandRecordRef,
string receiptSchema,
string receiptRecordRef,
string assetManifestRecordRef,
IReadOnlyList<string> loweringTargets,
string ownership)
{
ProjectionKind = projectionKind ?? "";
StateSchemas = stateSchemas ?? Array.Empty<string>();
CommandBoundary = commandBoundary ?? "";
CommandRecordRef = commandRecordRef ?? "";
ReceiptSchema = receiptSchema ?? "";
ReceiptRecordRef = receiptRecordRef ?? "";
AssetManifestRecordRef = assetManifestRecordRef ?? "";
LoweringTargets = loweringTargets ?? Array.Empty<string>();
Ownership = ownership ?? "";
}
[Key(0)] public string ProjectionKind { get; }
[Key(1)] public IReadOnlyList<string> StateSchemas { get; }
[Key(2)] public string CommandBoundary { get; }
[Key(3)] public string CommandRecordRef { get; }
[Key(4)] public string ReceiptSchema { get; }
[Key(5)] public string ReceiptRecordRef { get; }
[Key(6)] public string AssetManifestRecordRef { get; }
[Key(7)] public IReadOnlyList<string> LoweringTargets { get; }
[Key(8)] public string Ownership { get; }
}
[MessagePackObject]
public sealed class EveAdvertisedCommand
{
[SerializationConstructor]
public EveAdvertisedCommand(string command, string surfaceId, string transport, string summary)
{
Command = command ?? "";
SurfaceId = surfaceId ?? "";
Transport = transport ?? "";
Summary = summary ?? "";
}
[Key(0)] public string Command { get; }
[Key(1)] public string SurfaceId { get; }
[Key(2)] public string Transport { get; }
[Key(3)] public string Summary { get; }
}
[CultDocument("gamecult.eve.command_receipt", SchemaId)]
[MessagePackObject]
public sealed class EveCommandReceiptDocument
{
public const string SchemaId = "gamecult.eve.command_receipt.v1";
public EveCommandReceiptDocument(
string receiptId,
string commandId,
string command,
string state,
string ownerRepo,
string authority,
string providerId,
string surfaceId,
string message,
string issuedAtUtc,
long sourceVersion)
: this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion)
{
}
[SerializationConstructor]
public EveCommandReceiptDocument(
string schema,
string receiptId,
string commandId,
string command,
string state,
string ownerRepo,
string authority,
string providerId,
string surfaceId,
string message,
string issuedAtUtc,
long sourceVersion)
{
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
ReceiptId = receiptId ?? "";
CommandId = commandId ?? "";
Command = command ?? "";
State = state ?? "";
OwnerRepo = ownerRepo ?? "";
Authority = authority ?? "";
ProviderId = providerId ?? "";
SurfaceId = surfaceId ?? "";
Message = message ?? "";
IssuedAtUtc = issuedAtUtc ?? "";
SourceVersion = sourceVersion;
}
[Key(0)] public string Schema { get; }
[Key(1)] public string ReceiptId { get; }
[Key(2)] public string CommandId { get; }
[Key(3)] public string Command { get; }
[Key(4)] public string State { get; }
[Key(5)] public string OwnerRepo { get; }
[Key(6)] public string Authority { get; }
[Key(7)] public string ProviderId { get; }
[Key(8)] public string SurfaceId { get; }
[Key(9)] public string Message { get; }
[Key(10)] public string IssuedAtUtc { get; }
[Key(11)] public long SourceVersion { get; }
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f7ea0cd07c4d5da4099b5acf8d44ca2e

View file

@ -1,874 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Mesh;
#nullable enable
namespace GameCult.Eve.Surface
{
/// <summary>
/// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors.
/// </summary>
public sealed class EveSurfaceBuilder
{
private readonly string _surfaceId;
private readonly List<EveSurfaceComponent> _children = new();
private readonly List<EveStyleToken> _styles = new();
private readonly List<EveCommandTemplate> _commands = new();
private EveSurfaceComponent? _root;
private long _version = 1;
private string _providerId = "gamecult";
private string _providerKind = "cultui.surface";
private string _title = "";
private string _updatedAtUtc = "";
public EveSurfaceBuilder(string surfaceId)
{
_surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId));
}
public EveSurfaceBuilder Provider(string providerId, string providerKind)
{
_providerId = providerId ?? "";
_providerKind = providerKind ?? "";
return this;
}
public EveSurfaceBuilder Title(string title)
{
_title = title ?? "";
_children.Add(Text($"{_surfaceId}.title", _title, "text.title"));
return this;
}
public EveSurfaceBuilder TitleSubtitle(string title, string subtitle)
{
_title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}";
_children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title"));
_children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle"));
return this;
}
public EveSurfaceBuilder Text(string text, string? id = null)
{
_children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text"));
return this;
}
public EveSurfaceBuilder Button(string label, string command)
{
var operation = CultMesh.OperationBinding(command, label);
_commands.Add(new EveCommandTemplate(operation));
_children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation));
return this;
}
public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation)
{
if (operation == null) throw new ArgumentNullException(nameof(operation));
_commands.Add(new EveCommandTemplate(operation));
_children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation));
return this;
}
public EveSurfaceBuilder ButtonColumn(string id, Action<EveSurfaceGroupBuilder> build)
{
return Group(id, "column", build);
}
public EveSurfaceBuilder ButtonRow(string id, Action<EveSurfaceGroupBuilder> build)
{
return Group(id, "row", build);
}
public EveSurfaceBuilder Form(string id, Action<EveSurfaceFormBuilder> build)
{
if (build == null) throw new ArgumentNullException(nameof(build));
var form = new EveSurfaceFormBuilder(id, AddCommand);
build(form);
_children.Add(form.Build());
return this;
}
public EveSurfaceBuilder RootColumn(string id, Action<EveSurfaceContainerBuilder> build)
{
_root = BuildContainer(id, "partition", Props(("split", "y")), build);
return this;
}
public EveSurfaceBuilder RootRow(string id, Action<EveSurfaceContainerBuilder> build)
{
_root = BuildContainer(id, "partition", Props(("split", "x")), build);
return this;
}
public EveSurfaceBuilder RootGrid(
string id,
int columns,
int rows,
Action<EveSurfaceContainerBuilder> build)
{
_root = BuildContainer(
id,
"grid",
Props(
("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))),
build);
return this;
}
public EveSurfaceBuilder Row(string id, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", Props(("split", "x")), build));
return this;
}
public EveSurfaceBuilder Column(string id, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", Props(("split", "y")), build));
return this;
}
public EveSurfaceBuilder Grid(string id, int columns, int rows, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(
id,
"grid",
Props(
("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))),
build));
return this;
}
public EveSurfaceBuilder Modal(string id, string title, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "modal", Props(("title", title ?? "")), build));
return this;
}
public EveSurfaceBuilder EmbeddedDocument(
string slotId,
string documentId,
string schemaId,
string presentationKind,
CultMeshRouteHint? routeHint = null)
{
_children.Add(new EveSurfaceComponent(
$"{_surfaceId}.slot.{Slug(slotId)}",
"surface.slot",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["slotId"] = slotId ?? "",
["documentId"] = documentId ?? "",
["schemaId"] = schemaId ?? "",
["presentationKind"] = presentationKind ?? ""
},
Array.Empty<EveSurfaceComponent>(),
Array.Empty<CultMeshStateBindingDescriptor>(),
new[]
{
new EveEmbeddedDocumentSlot(
slotId ?? "",
documentId ?? "",
schemaId ?? "",
presentationKind ?? "",
routeHint)
}));
return this;
}
public EveSurfaceBuilder Style(string name, string value)
{
_styles.Add(new EveStyleToken(name, value));
return this;
}
public EveSurfaceBuilder Version(long version)
{
_version = version;
return this;
}
public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc)
{
_updatedAtUtc = updatedAtUtc ?? "";
return this;
}
public EveSurfaceDocument Build()
{
return new EveSurfaceDocument(
_providerId,
_providerKind,
_title,
_version,
string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc,
new EveSurfaceTree(
_surfaceId,
_root ?? new EveSurfaceComponent(
$"{_surfaceId}.root",
"surface",
EmptyProps(),
_children.ToArray()),
_styles.ToArray()),
_commands.ToArray());
}
private EveSurfaceBuilder Group(string id, string kind, Action<EveSurfaceGroupBuilder> build)
{
if (build == null) throw new ArgumentNullException(nameof(build));
var group = new EveSurfaceGroupBuilder(id, kind, AddCommand);
build(group);
_children.Add(group.Build());
return this;
}
private EveSurfaceComponent BuildContainer(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
Action<EveSurfaceContainerBuilder> build)
{
if (build == null) throw new ArgumentNullException(nameof(build));
var container = new EveSurfaceContainerBuilder(id, kind, props, AddCommand);
build(container);
return container.Build();
}
private void AddCommand(EveCommandTemplate command)
{
if (command != null)
_commands.Add(command);
}
internal static EveSurfaceComponent Text(string id, string value, string kind)
{
return new EveSurfaceComponent(
id,
kind,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["value"] = value ?? ""
},
Array.Empty<EveSurfaceComponent>());
}
internal static EveSurfaceComponent ButtonComponent(
string id,
string label,
CultMeshOperationBindingDescriptor operation)
{
return new EveSurfaceComponent(
id,
"control.button",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? operation.Label,
["command"] = operation.OperationId,
["operationId"] = operation.OperationId,
["schemaId"] = operation.SchemaId
},
Array.Empty<EveSurfaceComponent>());
}
internal static Dictionary<string, string> EmptyProps()
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
internal static Dictionary<string, string> Props(params (string Key, string Value)[] values)
{
return (values ?? Array.Empty<(string Key, string Value)>())
.ToDictionary(value => value.Key, value => value.Value ?? "", StringComparer.Ordinal);
}
internal static string Slug(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return "unnamed";
var chars = value
.Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.')
.ToArray();
var slug = new string(chars).Trim('.');
while (slug.Contains("..", StringComparison.Ordinal))
slug = slug.Replace("..", ".", StringComparison.Ordinal);
return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug;
}
private static string RequireNonEmpty(string value, string paramName)
{
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value must be non-empty.", paramName)
: value;
}
}
public sealed class EveSurfaceContainerBuilder
{
private readonly string _id;
private readonly string _kind;
private readonly Dictionary<string, string> _props;
private readonly Action<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
private readonly List<CultMeshStateBindingDescriptor> _stateBindings = new();
private readonly List<EveEmbeddedDocumentSlot> _embeddedDocuments = new();
internal EveSurfaceContainerBuilder(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
Action<EveCommandTemplate> addCommand)
{
_id = id ?? "";
_kind = kind ?? "partition";
_props = new Dictionary<string, string>(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal);
_addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand));
}
public EveSurfaceContainerBuilder Gap(int value) => Prop("gap", value);
public EveSurfaceContainerBuilder Padding(int value) => Prop("padding", value);
public EveSurfaceContainerBuilder Size(string value) => Prop("size", value);
public EveSurfaceContainerBuilder Min(string value) => Prop("min", value);
public EveSurfaceContainerBuilder Max(string value) => Prop("max", value);
public EveSurfaceContainerBuilder Align(string value) => Prop("align", value);
public EveSurfaceContainerBuilder Clip(string value = "hidden") => Prop("clip", value);
public EveSurfaceContainerBuilder Scroll(string value = "y") => Prop("scroll", value);
public EveSurfaceContainerBuilder Region(string value) => Prop("region", value);
public EveSurfaceContainerBuilder Bind(CultMeshStateBindingDescriptor binding)
{
if (binding != null)
_stateBindings.Add(binding);
return this;
}
public EveSurfaceContainerBuilder Row(string id, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build));
return this;
}
public EveSurfaceContainerBuilder Column(string id, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build));
return this;
}
public EveSurfaceContainerBuilder Grid(
string id,
int columns,
int rows,
Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(
id,
"grid",
EveSurfaceBuilder.Props(
("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))),
build));
return this;
}
public EveSurfaceContainerBuilder Pane(string id, string title, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build));
return this;
}
public EveSurfaceContainerBuilder Card(string id, string title, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build));
return this;
}
public EveSurfaceContainerBuilder Modal(string id, string title, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build));
return this;
}
public EveSurfaceContainerBuilder FieldRow(
string id,
string label,
Action<EveSurfaceContainerBuilder> build)
{
return Row(id, row => row
.Gap(8)
.Label($"{id}.label", label, labelPart => labelPart.Size("12rem"))
.Column($"{id}.field", field =>
{
field.Size("1fr");
build(field);
}));
}
public EveSurfaceContainerBuilder Label(
string id,
string text,
Action<EveSurfaceContainerBuilder>? configure = null)
{
return Leaf(id, "label", EveSurfaceBuilder.Props(("value", text ?? "")), null, null, configure);
}
public EveSurfaceContainerBuilder Text(string id, string text)
{
return Leaf(id, "text", EveSurfaceBuilder.Props(("value", text ?? "")));
}
public EveSurfaceContainerBuilder Title(string id, string text)
{
return Leaf(id, "text.title", EveSurfaceBuilder.Props(("value", text ?? "")));
}
public EveSurfaceContainerBuilder Metric(
string id,
string label,
string value,
CultMeshStateBindingDescriptor? binding = null)
{
return Leaf(
id,
"metric",
EveSurfaceBuilder.Props(("label", label ?? ""), ("value", value ?? "")),
binding);
}
public EveSurfaceContainerBuilder Progress(
string id,
string label,
string value,
string min,
string max,
CultMeshStateBindingDescriptor? binding = null)
{
return Leaf(
id,
"progress",
EveSurfaceBuilder.Props(
("label", label ?? ""),
("value", value ?? ""),
("min", min ?? ""),
("max", max ?? "")),
binding);
}
public EveSurfaceContainerBuilder Button(string id, string label, string command)
{
return Button(id, label, CultMesh.OperationBinding(command, label));
}
public EveSurfaceContainerBuilder Button(
string id,
string label,
CultMeshOperationBindingDescriptor operation)
{
if (operation == null) throw new ArgumentNullException(nameof(operation));
_addCommand(new EveCommandTemplate(operation));
_children.Add(EveSurfaceBuilder.ButtonComponent(id, label, operation));
return this;
}
public EveSurfaceContainerBuilder PopupButton(
string id,
string label,
string title,
Action<EveSurfaceContainerBuilder> build,
string anchor = "viewport",
string placement = "center",
int offsetX = 0,
int offsetY = 0)
{
if (build == null) throw new ArgumentNullException(nameof(build));
var popup = BuildContainer(
$"{id}.popup",
"modal",
EveSurfaceBuilder.Props(
("title", title ?? label ?? ""),
("anchor", anchor ?? "viewport"),
("placement", placement ?? "center"),
("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("presentation", "popover")),
build);
_children.Add(new EveSurfaceComponent(
id ?? "",
"control.popup",
EveSurfaceBuilder.Props(
("label", label ?? ""),
("title", title ?? label ?? ""),
("anchor", anchor ?? "viewport"),
("placement", placement ?? "center"),
("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture))),
new[] { popup }));
return this;
}
public EveSurfaceContainerBuilder EnumField(
string id,
string label,
string value,
CultMeshStateBindingDescriptor optionsBinding,
CultMeshOperationBindingDescriptor setValue,
string anchor = "trigger",
string placement = "right-down")
{
return FieldRow(id, label, field => field.PopupButton(
$"{id}.dropdown",
value,
label,
popup => popup.OptionList($"{id}.options", label, optionsBinding, setValue),
anchor,
placement));
}
public EveSurfaceContainerBuilder OptionList(
string id,
string label,
CultMeshStateBindingDescriptor itemsBinding,
CultMeshOperationBindingDescriptor operation)
{
if (itemsBinding == null) throw new ArgumentNullException(nameof(itemsBinding));
if (operation == null) throw new ArgumentNullException(nameof(operation));
_addCommand(new EveCommandTemplate(operation));
_children.Add(new EveSurfaceComponent(
id ?? "",
"options",
EveSurfaceBuilder.Props(
("label", label ?? ""),
("command", operation.OperationId),
("operationId", operation.OperationId),
("schemaId", operation.SchemaId)),
Array.Empty<EveSurfaceComponent>(),
new[] { itemsBinding }));
return this;
}
public EveSurfaceContainerBuilder TextInput(
string id,
string label,
string value,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding = null)
{
return Control("control.input.text", id, label, value, operation, binding);
}
public EveSurfaceContainerBuilder Toggle(
string id,
string label,
bool value,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding = null)
{
return Control("control.toggle", id, label, value ? "true" : "false", operation, binding);
}
public EveSurfaceContainerBuilder Slider(
string id,
string label,
string value,
string min,
string max,
string step,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding = null)
{
return Control(
"control.slider",
id,
label,
value,
operation,
binding,
("min", min ?? ""),
("max", max ?? ""),
("step", step ?? ""));
}
public EveSurfaceContainerBuilder Select(
string id,
string label,
string value,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding = null)
{
return Control("control.select", id, label, value, operation, binding);
}
public EveSurfaceContainerBuilder EmbeddedDocument(
string id,
string slotId,
string documentId,
string schemaId,
string presentationKind,
CultMeshRouteHint? routeHint = null)
{
_children.Add(new EveSurfaceComponent(
id ?? "",
"surface.slot",
EveSurfaceBuilder.Props(
("slotId", slotId ?? ""),
("documentId", documentId ?? ""),
("schemaId", schemaId ?? ""),
("presentationKind", presentationKind ?? "")),
Array.Empty<EveSurfaceComponent>(),
Array.Empty<CultMeshStateBindingDescriptor>(),
new[]
{
new EveEmbeddedDocumentSlot(
slotId ?? "",
documentId ?? "",
schemaId ?? "",
presentationKind ?? "",
routeHint)
}));
return this;
}
internal EveSurfaceComponent Build()
{
return new EveSurfaceComponent(
_id,
_kind,
_props,
_children.ToArray(),
_stateBindings.ToArray(),
_embeddedDocuments.ToArray());
}
private EveSurfaceContainerBuilder Prop(string key, int value)
{
_props[key] = value.ToString(System.Globalization.CultureInfo.InvariantCulture);
return this;
}
private EveSurfaceContainerBuilder Prop(string key, string value)
{
_props[key] = value ?? "";
return this;
}
private EveSurfaceContainerBuilder Leaf(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
CultMeshStateBindingDescriptor? binding = null,
IReadOnlyList<EveSurfaceComponent>? children = null,
Action<EveSurfaceContainerBuilder>? configure = null)
{
var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand);
configure?.Invoke(builder);
var component = new EveSurfaceComponent(
id ?? "",
kind ?? "",
new Dictionary<string, string>(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal),
children ?? Array.Empty<EveSurfaceComponent>(),
binding == null ? Array.Empty<CultMeshStateBindingDescriptor>() : new[] { binding });
if (builder._props.Count != (props?.Count ?? 0))
{
component = new EveSurfaceComponent(
id ?? "",
kind ?? "",
new Dictionary<string, string>(builder._props, StringComparer.Ordinal),
children ?? Array.Empty<EveSurfaceComponent>(),
binding == null ? Array.Empty<CultMeshStateBindingDescriptor>() : new[] { binding });
}
_children.Add(component);
return this;
}
private EveSurfaceContainerBuilder Control(
string kind,
string id,
string label,
string value,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding,
params (string Key, string Value)[] extraProps)
{
if (operation == null) throw new ArgumentNullException(nameof(operation));
_addCommand(new EveCommandTemplate(operation));
var props = EveSurfaceBuilder.Props(
new[]
{
("label", label ?? ""),
("value", value ?? ""),
("command", operation.OperationId),
("operationId", operation.OperationId),
("schemaId", operation.SchemaId)
}.Concat(extraProps ?? Array.Empty<(string Key, string Value)>()).ToArray());
return Leaf(kind: kind, id: id, props: props, binding: binding);
}
private EveSurfaceComponent BuildContainer(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
Action<EveSurfaceContainerBuilder> build)
{
if (build == null) throw new ArgumentNullException(nameof(build));
var container = new EveSurfaceContainerBuilder(id, kind, props, _addCommand);
build(container);
return container.Build();
}
}
public sealed class EveSurfaceGroupBuilder
{
private readonly string _id;
private readonly string _kind;
private readonly Action<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
internal EveSurfaceGroupBuilder(
string id,
string kind,
Action<EveCommandTemplate> addCommand)
{
_id = id ?? "";
_kind = kind ?? "column";
_addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand));
}
public EveSurfaceGroupBuilder Button(string label, string command)
{
var operation = CultMesh.OperationBinding(command, label);
_addCommand(new EveCommandTemplate(operation));
_children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation));
return this;
}
public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation)
{
if (operation == null) throw new ArgumentNullException(nameof(operation));
_addCommand(new EveCommandTemplate(operation));
_children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation));
return this;
}
internal EveSurfaceComponent Build()
{
return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray());
}
}
public sealed class EveSurfaceFormBuilder
{
private readonly string _id;
private readonly Action<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
internal EveSurfaceFormBuilder(string id, Action<EveCommandTemplate> addCommand)
{
_id = id ?? "";
_addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand));
}
public EveSurfaceFormBuilder Text(
string label,
string value,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding = null)
{
if (operation == null) throw new ArgumentNullException(nameof(operation));
_addCommand(new EveCommandTemplate(operation));
_children.Add(Control(
"control.text",
label,
value,
operation,
binding));
return this;
}
public EveSurfaceFormBuilder Toggle(
string label,
bool value,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding = null)
{
if (operation == null) throw new ArgumentNullException(nameof(operation));
_addCommand(new EveCommandTemplate(operation));
_children.Add(Control(
"control.toggle",
label,
value ? "true" : "false",
operation,
binding));
return this;
}
public EveSurfaceFormBuilder Metric(
string label,
string value,
CultMeshStateBindingDescriptor? binding = null)
{
var bindings = binding == null
? Array.Empty<CultMeshStateBindingDescriptor>()
: new[] { binding };
_children.Add(new EveSurfaceComponent(
$"{_id}.metric.{EveSurfaceBuilder.Slug(label)}",
"metric",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? "",
["value"] = value ?? ""
},
Array.Empty<EveSurfaceComponent>(),
bindings));
return this;
}
internal EveSurfaceComponent Build()
{
return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray());
}
private EveSurfaceComponent Control(
string kind,
string label,
string value,
CultMeshOperationBindingDescriptor operation,
CultMeshStateBindingDescriptor? binding)
{
var bindings = binding == null
? Array.Empty<CultMeshStateBindingDescriptor>()
: new[] { binding };
return new EveSurfaceComponent(
$"{_id}.{EveSurfaceBuilder.Slug(label)}",
kind,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? "",
["value"] = value ?? "",
["command"] = operation.OperationId,
["operationId"] = operation.OperationId,
["schemaId"] = operation.SchemaId
},
Array.Empty<EveSurfaceComponent>(),
bindings);
}
}
public static class EveSurface
{
public static EveSurfaceBuilder Create(string surfaceId)
{
return new EveSurfaceBuilder(surfaceId);
}
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 97ac7f757692aef4db64d75d0df6fe51

View file

@ -1,538 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using GameCult.Mesh;
using MessagePack;
using MessagePack.Formatters;
#nullable enable
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")]
[MessagePackObject]
[MessagePackFormatter(typeof(EveSurfaceDocumentCompatibilityFormatter))]
public sealed class EveSurfaceDocument
{
public const string DefaultType = "surface-state";
public const string SchemaId = "gamecult.eve.surface.v1";
public EveSurfaceDocument(
string providerId,
string providerKind,
string title,
long version,
string updatedAtUtc,
EveSurfaceTree surface,
IReadOnlyList<EveCommandTemplate> commands)
: this(DefaultType, SchemaId, providerId, providerKind, title, version, updatedAtUtc, surface, commands)
{
}
[SerializationConstructor]
public EveSurfaceDocument(
string type,
string schema,
string providerId,
string providerKind,
string title,
long version,
string updatedAtUtc,
EveSurfaceTree surface,
IReadOnlyList<EveCommandTemplate> commands)
{
Type = string.IsNullOrWhiteSpace(type) ? DefaultType : type;
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
ProviderId = providerId ?? "";
ProviderKind = providerKind ?? "";
Title = title ?? "";
Version = version;
UpdatedAtUtc = updatedAtUtc ?? "";
Surface = surface ?? throw new ArgumentNullException(nameof(surface));
Commands = commands ?? Array.Empty<EveCommandTemplate>();
}
[Key(0)]
public string Type { get; }
[Key(1)]
public string Schema { get; }
[Key(2)]
public string ProviderId { get; }
[Key(3)]
public string ProviderKind { get; }
[Key(4)]
public string Title { get; }
[Key(5)]
public long Version { get; }
[Key(6)]
public string UpdatedAtUtc { get; }
[Key(7)]
public EveSurfaceTree Surface { get; }
[Key(8)]
public IReadOnlyList<EveCommandTemplate> Commands { get; }
}
[MessagePackObject]
public sealed class EveSurfaceTree
{
[SerializationConstructor]
public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList<EveStyleToken> styles)
{
Id = id ?? "";
Root = root ?? throw new ArgumentNullException(nameof(root));
Styles = styles ?? Array.Empty<EveStyleToken>();
}
[Key(0)]
public string Id { get; }
[Key(1)]
public EveSurfaceComponent Root { get; }
[Key(2)]
public IReadOnlyList<EveStyleToken> Styles { get; }
}
[MessagePackObject]
public sealed class EveSurfaceComponent
{
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children)
: this(id, kind, props, children, StateBindingsFromProps(props))
{
}
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings)
: this(id, kind, props, children, stateBindings, Array.Empty<EveEmbeddedDocumentSlot>())
{
}
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings,
IReadOnlyList<EveEmbeddedDocumentSlot> embeddedDocuments,
IReadOnlyDictionary<string, string>? layout = null,
IReadOnlyDictionary<string, string>? style = null)
: this(
id,
kind,
props,
children,
(stateBindings ?? Array.Empty<CultMeshStateBindingDescriptor>())
.Select(CultMeshStateBindingRecord.FromBinding)
.ToArray(),
embeddedDocuments,
layout,
style)
{
}
[SerializationConstructor]
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
CultMeshStateBindingRecord[] stateBindingRecords,
IReadOnlyList<EveEmbeddedDocumentSlot> embeddedDocuments,
IReadOnlyDictionary<string, string>? layout = null,
IReadOnlyDictionary<string, string>? style = null)
{
Id = id ?? "";
Kind = kind ?? "";
Props = props ?? new Dictionary<string, string>(StringComparer.Ordinal);
Children = children ?? Array.Empty<EveSurfaceComponent>();
StateBindingRecords = stateBindingRecords ?? Array.Empty<CultMeshStateBindingRecord>();
EmbeddedDocuments = embeddedDocuments ?? Array.Empty<EveEmbeddedDocumentSlot>();
Layout = layout ?? new Dictionary<string, string>(StringComparer.Ordinal);
Style = style ?? new Dictionary<string, string>(StringComparer.Ordinal);
}
[Key(0)]
public string Id { get; }
[Key(1)]
public string Kind { get; }
[Key(2)]
public IReadOnlyDictionary<string, string> Props { get; }
[Key(3)]
public IReadOnlyList<EveSurfaceComponent> Children { get; }
[Key(4)]
public CultMeshStateBindingRecord[] StateBindingRecords { get; }
[IgnoreMember]
public IReadOnlyList<CultMeshStateBindingDescriptor> StateBindings =>
StateBindingRecords.Select(record => record.ToBinding()).ToArray();
[Key(5)]
public IReadOnlyList<EveEmbeddedDocumentSlot> EmbeddedDocuments { get; }
[Key(6)]
public IReadOnlyDictionary<string, string> Layout { get; }
[Key(7)]
public IReadOnlyDictionary<string, string> Style { get; }
public string GetProp(string key, string fallback = "")
{
return Props.TryGetValue(key, out var value) ? value : fallback;
}
private static IReadOnlyList<CultMeshStateBindingDescriptor> StateBindingsFromProps(
IReadOnlyDictionary<string, string>? props)
{
if (props == null || props.Count == 0)
return Array.Empty<CultMeshStateBindingDescriptor>();
var bindings = new List<CultMeshStateBindingDescriptor>();
foreach (var prop in props)
{
if (string.IsNullOrWhiteSpace(prop.Value) ||
!prop.Key.EndsWith("PointerId", StringComparison.Ordinal))
{
continue;
}
var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length);
if (targetProp.Length == 0)
targetProp = "value";
bindings.Add(new CultMeshStateBindingDescriptor(targetProp, prop.Value));
}
return bindings;
}
}
[MessagePackObject]
public sealed class EveEmbeddedDocumentSlot
{
public EveEmbeddedDocumentSlot(
string slotId,
string documentId,
string schemaId,
string presentationKind,
CultMeshRouteHint? routeHint = null)
: this(slotId, documentId, schemaId, presentationKind, CultMeshRouteRecord.FromRoute(routeHint))
{
}
[SerializationConstructor]
public EveEmbeddedDocumentSlot(
string slotId,
string documentId,
string schemaId,
string presentationKind,
CultMeshRouteRecord route)
{
SlotId = slotId ?? "";
DocumentId = documentId ?? "";
SchemaId = schemaId ?? "";
PresentationKind = presentationKind ?? "";
Route = route ?? CultMeshRouteRecord.FromRoute(CultMeshRouteHint.Automatic);
}
[Key(0)]
public string SlotId { get; }
[Key(1)]
public string DocumentId { get; }
[Key(2)]
public string SchemaId { get; }
[Key(3)]
public string PresentationKind { get; }
[Key(4)]
public CultMeshRouteRecord Route { get; }
[IgnoreMember]
public CultMeshRouteHint RouteHint => Route.ToRoute();
}
[MessagePackObject]
public sealed class EveStyleToken
{
[SerializationConstructor]
public EveStyleToken(string name, string value)
{
Name = name ?? "";
Value = value ?? "";
}
[Key(0)]
public string Name { get; }
[Key(1)]
public string Value { get; }
}
[MessagePackObject]
[MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))]
public sealed class EveCommandTemplate
{
public EveCommandTemplate(CultMeshOperationBindingDescriptor operation)
: this(CultMeshOperationBindingRecord.FromBinding(operation))
{
}
[SerializationConstructor]
public EveCommandTemplate(CultMeshOperationBindingRecord operationRecord)
{
OperationRecord = operationRecord ?? throw new ArgumentNullException(nameof(operationRecord));
}
[Key(0)]
public CultMeshOperationBindingRecord OperationRecord { get; }
[IgnoreMember]
public CultMeshOperationBindingDescriptor Operation => OperationRecord.ToBinding();
[IgnoreMember]
public string Command => Operation.OperationId;
[IgnoreMember]
public string Label => Operation.Label;
[IgnoreMember]
public string Transport => Operation.RouteHint.Description ?? "";
}
[CultDocument("gamecult.eve.command_invocation", SchemaId)]
[MessagePackObject]
public sealed class EveSurfaceCommandRequest
{
public const string SchemaId = "gamecult.eve.command_invocation.v1";
public EveSurfaceCommandRequest(
string providerId,
string surfaceId,
CultMeshOperationInvocationDescriptor operation,
CultMeshOperationPayload payload,
DateTimeOffset issuedAt,
string clientId,
string commandBoundary = "",
string receiptSchema = "")
: this(
SchemaId,
providerId,
surfaceId,
CultMeshOperationInvocationRecord.FromInvocation(operation),
payload?.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal)
?? new Dictionary<string, string>(StringComparer.Ordinal),
issuedAt,
clientId,
commandBoundary,
receiptSchema)
{
}
[SerializationConstructor]
public EveSurfaceCommandRequest(
string schema,
string providerId,
string surfaceId,
CultMeshOperationInvocationRecord operation,
Dictionary<string, string> payload,
DateTimeOffset issuedAt,
string clientId,
string commandBoundary,
string receiptSchema)
{
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
ProviderId = providerId;
SurfaceId = surfaceId;
OperationRecord = operation ?? throw new ArgumentNullException(nameof(operation));
PayloadFields = payload == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(payload, StringComparer.Ordinal);
IssuedAt = issuedAt;
ClientId = clientId;
CommandBoundary = commandBoundary ?? "";
ReceiptSchema = receiptSchema ?? "";
}
[Key(0)]
public string Schema { get; }
[Key(1)]
public string ProviderId { get; }
[Key(2)]
public string SurfaceId { get; }
[Key(3)]
public CultMeshOperationInvocationRecord OperationRecord { get; }
[IgnoreMember]
public CultMeshOperationInvocationDescriptor Operation => OperationRecord.ToInvocation();
[IgnoreMember]
public string Command => Operation.OperationId;
[Key(4)]
public Dictionary<string, string> PayloadFields { get; }
[IgnoreMember]
public CultMeshOperationPayload Payload => new CultMeshOperationPayload(PayloadFields);
[Key(5)]
public DateTimeOffset IssuedAt { get; }
[Key(6)]
public string ClientId { get; }
[Key(7)]
public string CommandBoundary { get; }
[Key(8)]
public string ReceiptSchema { get; }
[IgnoreMember]
public string CommandId => Operation.IdempotencyKey ?? "";
}
/// <summary>
/// Reads the current Eve surface envelope and the original seven-field v1 envelope.
/// </summary>
public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter<EveSurfaceDocument?>
{
private const int LegacyFieldCount = 7;
private const int CurrentFieldCount = 9;
public void Serialize(ref MessagePackWriter writer, EveSurfaceDocument? value, MessagePackSerializerOptions options)
{
if (value == null) { writer.WriteNil(); return; }
writer.WriteArrayHeader(CurrentFieldCount);
writer.Write(value.Type);
writer.Write(value.Schema);
writer.Write(value.ProviderId);
writer.Write(value.ProviderKind);
writer.Write(value.Title);
writer.Write(value.Version);
writer.Write(value.UpdatedAtUtc);
Formatter<EveSurfaceTree>(options).Serialize(ref writer, value.Surface, options);
Formatter<IReadOnlyList<EveCommandTemplate>>(options).Serialize(ref writer, value.Commands, options);
}
public EveSurfaceDocument? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
if (reader.TryReadNil()) return null;
if (reader.NextMessagePackType != MessagePackType.Array)
throw new MessagePackSerializationException("Eve surface document must be an array.");
options.Security.DepthStep(ref reader);
try
{
var fields = reader.ReadArrayHeader();
if (fields == LegacyFieldCount)
{
return new EveSurfaceDocument(
ReadString(ref reader), ReadString(ref reader), ReadString(ref reader),
reader.ReadInt64(), ReadString(ref reader),
Formatter<EveSurfaceTree>(options).Deserialize(ref reader, options)!,
Formatter<IReadOnlyList<EveCommandTemplate>>(options).Deserialize(ref reader, options)
?? Array.Empty<EveCommandTemplate>());
}
var type = ReadString(ref reader, fields, 0);
var schema = ReadString(ref reader, fields, 1);
var providerId = ReadString(ref reader, fields, 2);
var providerKind = ReadString(ref reader, fields, 3);
var title = ReadString(ref reader, fields, 4);
var version = fields > 5 ? reader.ReadInt64() : 0;
var updatedAtUtc = ReadString(ref reader, fields, 6);
var surface = fields > 7 ? Formatter<EveSurfaceTree>(options).Deserialize(ref reader, options) : null;
var commands = fields > 8 ? Formatter<IReadOnlyList<EveCommandTemplate>>(options).Deserialize(ref reader, options) : null;
for (var index = CurrentFieldCount; index < fields; index++) reader.Skip();
return new EveSurfaceDocument(
type, schema, providerId, providerKind, title, version, updatedAtUtc,
surface ?? throw new MessagePackSerializationException("Eve surface tree is required."),
commands ?? Array.Empty<EveCommandTemplate>());
}
finally { reader.Depth--; }
}
private static IMessagePackFormatter<T> Formatter<T>(MessagePackSerializerOptions options) =>
options.Resolver.GetFormatter<T>()
?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}.");
private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? "";
private static string ReadString(ref MessagePackReader reader, int fields, int index) =>
index < fields ? ReadString(ref reader) : "";
}
/// <summary>
/// Reads both direct legacy command fields and the current wrapped binding record.
/// </summary>
public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter<EveCommandTemplate?>
{
public void Serialize(ref MessagePackWriter writer, EveCommandTemplate? value, MessagePackSerializerOptions options)
{
if (value == null) { writer.WriteNil(); return; }
writer.WriteArrayHeader(1);
BindingFormatter(options).Serialize(ref writer, value.OperationRecord, options);
}
public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
if (reader.TryReadNil()) return null;
if (reader.NextMessagePackType != MessagePackType.Array)
throw new MessagePackSerializationException("Eve command template must be an array.");
options.Security.DepthStep(ref reader);
try
{
var fields = reader.ReadArrayHeader();
if (fields == 1 && reader.NextMessagePackType == MessagePackType.Array)
{
return new EveCommandTemplate(
BindingFormatter(options).Deserialize(ref reader, options)
?? new CultMeshOperationBindingRecord());
}
var operationId = ReadString(ref reader, fields, 0);
var label = ReadString(ref reader, fields, 1);
var schemaId = ReadString(ref reader, fields, 2);
var routeKind = ReadString(ref reader, fields, 3);
var routeDescription = ReadString(ref reader, fields, 4);
for (var index = 5; index < fields; index++) reader.Skip();
return new EveCommandTemplate(new CultMeshOperationBindingRecord(
operationId, label, schemaId, routeKind, routeDescription));
}
finally { reader.Depth--; }
}
private static IMessagePackFormatter<CultMeshOperationBindingRecord> BindingFormatter(
MessagePackSerializerOptions options) =>
options.Resolver.GetFormatter<CultMeshOperationBindingRecord>()
?? throw new MessagePackSerializationException("No CultMesh operation binding formatter is registered.");
private static string ReadString(ref MessagePackReader reader, int fields, int index) =>
index < fields ? reader.ReadString() ?? "" : "";
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6516c42104b235f42b15efeb4639bb73

View file

@ -1,21 +0,0 @@
{
"name": "GameCult.Eve.Surface",
"rootNamespace": "GameCult.Eve.Surface",
"references": [
"GameCult.Mesh"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"GameCult.Caching.dll",
"GameCult.Mesh.dll",
"MessagePack.Annotations.dll",
"MessagePack.dll"
],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: a9004278c14816345941af2f2ce673ca
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,29 +0,0 @@
{
"name": "org.gamecult.eve.surface",
"displayName": "Eve Surface Contract",
"version": "0.2.4",
"unity": "2021.3",
"description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.",
"dependencies": {
"org.gamecult.cultlib": "1.0.8"
},
"files": [
"Runtime",
"Runtime.meta",
"README.md",
"README.md.meta",
"Directory.Build.props",
"Directory.Build.props.meta",
"GameCult.Eve.Surface.csproj",
"GameCult.Eve.Surface.csproj.meta",
"package.json.meta"
],
"keywords": [
"Eve",
"CultUI",
"CultMesh",
"Surface"
],
"license": "MPL-2.0",
"category": "Scripting"
}

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 49f1738bd94075e489c82190cf660b1d
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

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

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

@ -2,6 +2,8 @@ using System;
using System.IO;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Stopwatch = System.Diagnostics.Stopwatch;
using GameCult.Eve.Surface;
@ -9,6 +11,7 @@ using GameCult.Eve.PluginFields;
using GameCult.Eve.UnityScene.Fields;
using GameCult.Mesh;
using UnityEngine;
using UnityEngine.Serialization;
#nullable enable
@ -26,19 +29,29 @@ namespace GameCult.Eve.UnityScene
IEveUnityCameraRenderPolicySource,
IEveUnityNativeAssetProvider,
IEveUnityNativeAssetMetadataProvider,
IEveUnityInputCapabilitySource
IEveUnityInputCapabilitySource,
IEveUnityNavigableProvider
{
[SerializeField] private string rendezvousEndpoint = "";
[SerializeField] private string providerFilter = "";
[SerializeField] private string surfaceFilter = "";
[SerializeField] private string verseFilter = "";
[SerializeField] private string surfaceKind = "interactive-world";
[SerializeField] private string replicaPath = "";
[FormerlySerializedAs("replicaPath")]
[SerializeField] private string cacheDirectory = "";
[SerializeField] private string runtimeId = "eve-unity";
private CultMeshAuthorityTrustPolicy _authorityTrust = new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote);
private CultMeshAuthorityTrustPolicy _navigationAuthorityTrust = new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote);
private EveUnityCultMeshLiveProviderTransport? _transport;
private EveUnitySceneLiveProviderBridge? _bridge;
private PreviousProvider? _previousProvider;
private bool _navigationRouteCommitted;
private int _connectionLeaseCount;
private Task? _preparation;
private CancellationTokenSource? _preparationLifetime;
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
private readonly ConcurrentQueue<EveFieldsSplatsDocument> _pendingFields = new ConcurrentQueue<EveFieldsSplatsDocument>();
@ -76,28 +89,36 @@ namespace GameCult.Eve.UnityScene
public void Configure(
string endpoint,
string replicaStorePath = "",
string localCacheDirectory = "",
string providerId = "",
string surfaceId = "",
string verseId = "",
string requiredSurfaceKind = "interactive-world",
string clientRuntimeId = "eve-unity")
string clientRuntimeId = "eve-unity",
CultMeshAuthorityTrustPolicy? authorityTrust = null,
CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null)
{
if (_bridge != null || _preparation != null)
throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration.");
rendezvousEndpoint = endpoint ?? "";
replicaPath = replicaStorePath ?? "";
cacheDirectory = localCacheDirectory ?? "";
providerFilter = providerId ?? "";
surfaceFilter = surfaceId ?? "";
verseFilter = verseId ?? "";
surfaceKind = string.IsNullOrWhiteSpace(requiredSurfaceKind) ? "interactive-world" : requiredSurfaceKind;
runtimeId = string.IsNullOrWhiteSpace(clientRuntimeId) ? "eve-unity" : clientRuntimeId;
_authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote);
_navigationAuthorityTrust = navigationAuthorityTrust ?? _authorityTrust;
}
public void Connect()
{
// During navigation the candidate presentation connects before the route
// becomes canonical, so every new lease must connect its selected bridge.
Bridge.Connect();
checked { _connectionLeaseCount++; }
}
public Task PrepareAsync()
@ -105,26 +126,132 @@ namespace GameCult.Eve.UnityScene
if (_bridge != null)
return Task.CompletedTask;
if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted)
_preparation = PrepareTransportAsync();
{
_preparationLifetime?.Dispose();
_preparationLifetime = new CancellationTokenSource();
_preparation = PrepareTransportAsync(_preparationLifetime.Token);
}
return _preparation;
}
public void Disconnect()
{
_bridge?.Disconnect();
if (_connectionLeaseCount <= 0)
return;
_connectionLeaseCount--;
if (_connectionLeaseCount == 0)
_bridge?.Disconnect();
}
public async Task NavigateAsync(EveUnitySceneNavigationTarget target)
{
if (target == null) throw new ArgumentNullException(nameof(target));
if (string.IsNullOrWhiteSpace(target.VerseId))
throw new ArgumentException("Eve provider navigation requires a stable Verse identity.", nameof(target));
if (string.IsNullOrWhiteSpace(target.SurfaceId))
throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target));
var endpoints = (target.RendezvousEndpoints ?? Array.Empty<string>())
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint))
.Select(endpoint => endpoint.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
if (endpoints.Length == 0 && !string.IsNullOrWhiteSpace(rendezvousEndpoint))
endpoints = new[] { rendezvousEndpoint };
if (endpoints.Length == 0)
throw new InvalidOperationException("Eve provider navigation requires at least one Odin rendezvous endpoint.");
var requiredSurfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind)
? "interactive-world"
: target.SurfaceKind;
var prepared = await PrepareCandidateAsync(
endpoints,
target.ProviderId,
target.SurfaceId,
requiredSurfaceKind,
target.VerseId,
target.AuthorityRuntimeId,
_navigationAuthorityTrust,
_navigationAuthorityTrust,
CancellationToken.None).ConfigureAwait(false);
StagePrepared(prepared);
rendezvousEndpoint = prepared.Selection.RendezvousEndpoint;
verseFilter = target.VerseId;
providerFilter = target.ProviderId;
surfaceFilter = target.SurfaceId;
surfaceKind = requiredSurfaceKind;
_authorityTrust = _navigationAuthorityTrust;
_preparation = Task.CompletedTask;
}
public void CommitNavigation()
{
var previous = _previousProvider;
if (previous == null) return;
if (_navigationRouteCommitted) return;
if (previous.Bridge != null)
previous.Bridge.ReceiptAvailable -= ForwardReceipt;
AttachPrepared(_transport, _bridge);
_navigationRouteCommitted = true;
}
public void FinalizeNavigation()
{
var previous = _previousProvider;
if (previous == null) return;
_previousProvider = null;
_navigationRouteCommitted = false;
SafeDispose(previous.Bridge);
SafeDispose(previous.Transport);
}
public void RollbackNavigation()
{
var previous = _previousProvider;
if (previous == null) return;
if (previous.Bridge != null)
previous.Bridge.ReceiptAvailable -= ForwardReceipt;
DetachPrepared(_transport, _bridge);
var rejectedBridge = _bridge;
var rejectedTransport = _transport;
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
while (_pendingFields.TryDequeue(out _)) { }
Selection = previous.Selection;
_transport = previous.Transport;
_bridge = previous.Bridge;
rendezvousEndpoint = previous.RendezvousEndpoint;
verseFilter = previous.VerseFilter;
providerFilter = previous.ProviderFilter;
surfaceFilter = previous.SurfaceFilter;
surfaceKind = previous.SurfaceKind;
_authorityTrust = previous.AuthorityTrust;
AttachPrepared(_transport, _bridge);
_previousProvider = null;
_navigationRouteCommitted = false;
_preparation = Task.CompletedTask;
SafeDispose(rejectedBridge);
SafeDispose(rejectedTransport);
}
public void Refresh()
{
if (Bridge.IsConnected)
Bridge.Refresh();
var activeBridge = _navigationRouteCommitted
? Bridge
: _previousProvider?.Bridge ?? Bridge;
if (activeBridge.IsConnected)
activeBridge.Refresh();
else
Bridge.Connect();
activeBridge.Connect();
}
public void Submit(EveSurfaceCommandRequest request)
{
Bridge.Submit(request);
// Navigation preparation is not a command-routing commit. The mounted
// presentation continues to target its old provider until the visual
// candidate has been lowered and accepted by the bootstrap.
(_navigationRouteCommitted ? Bridge : _previousProvider?.Bridge ?? Bridge).Submit(request);
}
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
@ -155,6 +282,12 @@ namespace GameCult.Eve.UnityScene
private void OnDestroy()
{
ReleaseTransport();
}
private void ReleaseTransport()
{
_preparationLifetime?.Cancel();
if (_bridge != null)
{
_bridge.DocumentAvailable -= ForwardDocument;
@ -162,16 +295,24 @@ namespace GameCult.Eve.UnityScene
}
_bridge?.Dispose();
_transport?.Dispose();
_previousProvider?.Bridge?.Dispose();
_previousProvider?.Transport?.Dispose();
_previousProvider = null;
_navigationRouteCommitted = false;
_connectionLeaseCount = 0;
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
while (_pendingFields.TryDequeue(out _)) { }
_bridge = null;
_transport = null;
_preparation = null;
_preparationLifetime?.Dispose();
_preparationLifetime = null;
Selection = null;
}
private void Update()
{
_previousProvider?.Transport?.PumpLiveEvents();
_transport?.PumpLiveEvents();
EveFieldsSplatsDocument? latestFields = null;
while (_pendingFields.TryDequeue(out var fields))
@ -199,7 +340,7 @@ namespace GameCult.Eve.UnityScene
}
}
private async Task PrepareTransportAsync()
private async Task PrepareTransportAsync(CancellationToken cancellationToken)
{
var elapsed = Stopwatch.StartNew();
if (_transport != null)
@ -207,37 +348,183 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync(
rendezvousEndpoint,
var prepared = await PrepareCandidateAsync(
new[] { rendezvousEndpoint },
providerFilter,
surfaceFilter,
surfaceKind,
verseFilter);
TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
elapsed.Restart();
var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath)
? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc")
: replicaPath;
_transport = new EveUnityCultMeshLiveProviderTransport(
resolvedReplicaPath,
Selection.Endpoint,
Selection.ProviderId,
Selection.SurfaceId,
runtimeId);
_transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
_transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields);
_bridge = new EveUnitySceneLiveProviderBridge(_transport);
_bridge.DocumentAvailable += ForwardDocument;
_bridge.ReceiptAvailable += ForwardReceipt;
TraceStartup($"transport-construction {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
verseFilter,
"",
_authorityTrust,
_navigationAuthorityTrust,
cancellationToken).ConfigureAwait(false);
TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
AdoptPrepared(prepared);
}
private async Task<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))
@ -262,5 +549,57 @@ namespace GameCult.Eve.UnityScene
public EveEntitySoaViewDocument Document { get; }
public ICultMeshBodyReadLease Lease { get; }
}
private sealed class PreparedProvider
{
public PreparedProvider(
EveUnityCultMeshProviderSelection selection,
EveUnityCultMeshLiveProviderTransport transport,
EveUnitySceneLiveProviderBridge bridge)
{
Selection = selection;
Transport = transport;
Bridge = bridge;
}
public EveUnityCultMeshProviderSelection Selection { get; }
public EveUnityCultMeshLiveProviderTransport Transport { get; }
public EveUnitySceneLiveProviderBridge Bridge { get; }
}
private sealed class PreviousProvider
{
public PreviousProvider(
EveUnityCultMeshProviderSelection? selection,
EveUnityCultMeshLiveProviderTransport? transport,
EveUnitySceneLiveProviderBridge? bridge,
string rendezvousEndpoint,
string verseFilter,
string providerFilter,
string surfaceFilter,
string surfaceKind,
CultMeshAuthorityTrustPolicy authorityTrust)
{
Selection = selection;
Transport = transport;
Bridge = bridge;
RendezvousEndpoint = rendezvousEndpoint;
VerseFilter = verseFilter;
ProviderFilter = providerFilter;
SurfaceFilter = surfaceFilter;
SurfaceKind = surfaceKind;
AuthorityTrust = authorityTrust;
}
public EveUnityCultMeshProviderSelection? Selection { get; }
public EveUnityCultMeshLiveProviderTransport? Transport { get; }
public EveUnitySceneLiveProviderBridge? Bridge { get; }
public string RendezvousEndpoint { get; }
public string VerseFilter { get; }
public string ProviderFilter { get; }
public string SurfaceFilter { get; }
public string SurfaceKind { get; }
public CultMeshAuthorityTrustPolicy AuthorityTrust { get; }
}
}
}

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;
@ -13,21 +14,24 @@ namespace GameCult.Eve.UnityScene
public sealed class EveUnityCultMeshProviderSelection
{
public EveUnityCultMeshProviderSelection(
string endpoint,
string rendezvousEndpoint,
string verseId,
string authorityRuntimeId,
string providerId,
string surfaceId,
string surfaceKind)
{
Endpoint = endpoint ?? "";
RendezvousEndpoint = rendezvousEndpoint ?? "";
VerseId = verseId ?? "";
AuthorityRuntimeId = authorityRuntimeId ?? "";
ProviderId = providerId ?? "";
SurfaceId = surfaceId ?? "";
SurfaceKind = surfaceKind ?? "";
}
public string Endpoint { get; }
public string RendezvousEndpoint { get; }
public string VerseId { get; }
public string AuthorityRuntimeId { get; }
public string ProviderId { get; }
public string SurfaceId { get; }
public string SurfaceKind { get; }
@ -35,26 +39,22 @@ namespace GameCult.Eve.UnityScene
public sealed class EveUnityCultMeshProviderDiscovery
{
private static readonly Type[] AdvertisementDocumentTypes =
{
typeof(EveProviderAdvertisementDocument)
};
private readonly CultMeshAuthorityTrustPolicy _authorityTrust;
public EveUnityCultMeshProviderSelection Discover(
string rendezvousEndpoint,
string providerId = "",
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "") =>
DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId)
.GetAwaiter().GetResult();
public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null)
{
_authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote);
}
public async Task<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));
@ -62,13 +62,14 @@ namespace GameCult.Eve.UnityScene
CultMeshVerseCatalogResponseMessage response;
try
{
response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync(
response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync(
rendezvousEndpoint,
new CultMeshVerseCatalogRequestMessage
{
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
TransportVersion = "cultmesh.v0"
});
cancellationToken.ThrowIfCancellationRequested();
}
catch (Exception error)
{
@ -80,79 +81,87 @@ namespace GameCult.Eve.UnityScene
var candidates = response.Verses
.Where(verse => string.IsNullOrWhiteSpace(verseId) ||
string.Equals(verse.VerseId, verseId, StringComparison.Ordinal))
.SelectMany(verse => (verse.DiscoveryEndpoints ?? Array.Empty<string>())
.Select(endpoint => new { Verse = verse, Endpoint = endpoint }))
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Endpoint))
.Where(verse => !string.IsNullOrWhiteSpace(verse.VerseId) &&
(verse.DiscoveryEndpoints ?? Array.Empty<string>()).Any(endpoint =>
!string.IsNullOrWhiteSpace(endpoint)))
.GroupBy(verse => verse.VerseId, StringComparer.Ordinal)
.Select(group => group.First())
.ToArray();
if (candidates.Length == 0)
throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints.");
var failures = new List<string>();
var observed = new List<string>();
using var mesh = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { rendezvousEndpoint },
Discovery = EveUnityCultMeshConnectivity.Discovery(),
Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust },
Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(),
ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors()
});
foreach (var candidate in candidates)
{
try
foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds(
candidate.AuthorityRuntimeIds,
requiredAuthorityRuntimeId))
{
var advertisements = await FetchAdvertisementsAsync(candidate.Endpoint);
observed.AddRange(advertisements.Select(document =>
$"{candidate.Endpoint}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]"));
var advertisement = advertisements
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
.Select(document => new
{
Document = document,
Surface = document.Surfaces.FirstOrDefault(surface =>
(string.IsNullOrWhiteSpace(surfaceId) ||
string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) &&
(string.IsNullOrWhiteSpace(surfaceKind) ||
string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal)))
})
.FirstOrDefault(match => match.Surface != null);
if (advertisement?.Surface == null)
continue;
try
{
var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId);
using var advertisementsLease = await mesh
.LeaseCollectionAsync<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(
candidate.Endpoint,
candidate.Verse.VerseId,
advertisement.Document.ProviderId,
advertisement.Surface.SurfaceId,
advertisement.Surface.SurfaceKind);
}
catch (Exception error)
{
failures.Add($"{candidate.Endpoint}: {error.Message}");
return new EveUnityCultMeshProviderSelection(
rendezvousEndpoint,
candidate.VerseId,
authorityRuntimeId,
advertisement.Document.ProviderId,
advertisement.Surface.SurfaceId,
advertisement.Surface.SurfaceKind);
}
catch (Exception error)
{
failures.Add($"{candidate.VerseId}/{authorityRuntimeId}: {error.Message}");
}
}
}
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'";
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'";
var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}";
var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}";
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}");
}
private static async Task<EveProviderAdvertisementDocument[]> FetchAdvertisementsAsync(string endpoint)
{
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes);
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry);
var snapshot = CultMesh.SnapshotEndpoint(
endpoint,
new CultMeshSnapshotEndpointOptions
{
Context = CultMesh.Verse("eve.discovery", "eve-unity").Context,
DocumentRegistry = networkRegistry,
Request = new CultMeshSnapshotRequestOptions
{
ShardId = "provider",
ShardEpoch = 1,
ConnectTimeout = TimeSpan.FromSeconds(5),
ResponseTimeout = TimeSpan.FromSeconds(10),
MessageIdPrefix = "eve-unity-discovery",
RudpRuntimeId = "eve-unity.discovery",
RudpMaxFragmentBytes = 1024
}
});
return (await snapshot.FetchDocumentsAsync<EveProviderAdvertisementDocument>()).ToArray();
}
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

@ -252,18 +252,7 @@ namespace GameCult.Eve.UnityScene
AttachToDocument();
return;
}
_document = GetComponent<UIDocument>();
if (_document == null)
{
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;
_document = gameObject.AddComponent<UIDocument>();
_document.panelSettings = panel;
_document.sortingOrder = 100;
}
_document = EveUnityUiDocumentRuntime.Ensure(gameObject);
_root = new VisualElement { name = "eve-input-action-bar" };
_root.AddToClassList("eve-input-action-bar");
_root.style.position = Position.Absolute;

View file

@ -1,4 +1,5 @@
using System;
using System.Collections;
using UnityEngine;
#nullable enable
@ -29,13 +30,378 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; }
public EveUnitySceneNavigationFailure? LastNavigationFailure { get; private set; }
public event Action<EveUnitySceneNavigationFailure>? NavigationFailed;
private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource;
private bool _navigationInProgress;
private StagedPresentation? _ownedPresentation;
public void ConfigureProvider(MonoBehaviour providerComponent)
{
UnbindNavigation();
provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent));
providerSurfaceDocuments = provider;
commandSink = provider;
assetManifestDocuments = provider;
receiptSource = provider;
BindNavigation();
}
private void OnDestroy()
{
UnbindNavigation();
_ownedPresentation?.Dispose();
_ownedPresentation = null;
}
private void BindNavigation()
{
if (provider is not IEveUnityNavigableProvider || provider is not IEveUnitySceneCommandReceiptSource source)
return;
_navigationReceiptSource = source;
source.ReceiptAvailable += OnReceiptAvailable;
}
private void UnbindNavigation()
{
if (_navigationReceiptSource != null)
_navigationReceiptSource.ReceiptAvailable -= OnReceiptAvailable;
_navigationReceiptSource = null;
}
private void OnReceiptAvailable(EveUnitySceneCommandReceipt receipt)
{
if (_navigationInProgress || receipt.Navigation == null)
return;
if (!string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))
return;
StartCoroutine(NavigateAsync(receipt.Navigation));
}
private IEnumerator NavigateAsync(EveUnitySceneNavigationTarget target)
{
if (provider is not IEveUnityNavigableProvider navigable)
yield break;
_navigationInProgress = true;
var navigation = navigable.NavigateAsync(target);
StagedPresentation? staged = null;
PresentationSwap? swap = null;
Transform? previousRoot = null;
var previousRootWasActive = false;
while (!navigation.IsCompleted)
yield return null;
try
{
navigation.GetAwaiter().GetResult();
staged = PrepareStagedPresentation();
previousRoot = sceneRoot;
previousRootWasActive = previousRoot == null || previousRoot.gameObject.activeSelf;
if (previousRoot != null && !ReferenceEquals(previousRoot, transform) &&
!ReferenceEquals(previousRoot, staged.SceneRoot))
previousRoot.gameObject.SetActive(false);
navigable.CommitNavigation();
swap = ActivateStagedPresentation(staged, previousRootWasActive);
navigable.FinalizeNavigation();
swap.Complete();
swap = null;
staged = null;
LastNavigationFailure = null;
}
catch (Exception error)
{
var providerRestored = false;
try
{
navigable.RollbackNavigation();
providerRestored = true;
}
catch (Exception rollbackError)
{
Debug.LogError($"Eve provider route rollback failed; the previous presentation will remain inactive. {rollbackError}");
}
if (providerRestored)
{
try
{
if (swap != null)
swap.Rollback();
else if (previousRoot != null)
previousRoot.gameObject.SetActive(previousRootWasActive);
}
catch (Exception rollbackError)
{
Debug.LogError($"Eve previous presentation rollback failed after provider restoration. {rollbackError}");
}
}
try
{
staged?.Dispose();
}
catch (Exception cleanupError)
{
Debug.LogWarning($"Eve rejected presentation cleanup failed. {cleanupError}");
}
var failure = new EveUnitySceneNavigationFailure(target, error.Message, DateTimeOffset.UtcNow);
LastNavigationFailure = failure;
NavigationFailed?.Invoke(failure);
Debug.LogError($"Eve provider navigation failed; the current surface remains mounted. {error}");
}
finally
{
_navigationInProgress = false;
}
}
private StagedPresentation PrepareStagedPresentation()
{
var resolvedProvider = provider;
var surfaceDocuments = ResolveBehaviour<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()
@ -191,4 +557,21 @@ namespace GameCult.Eve.UnityScene
return null;
}
}
public sealed class EveUnitySceneNavigationFailure
{
public EveUnitySceneNavigationFailure(
EveUnitySceneNavigationTarget target,
string diagnostic,
DateTimeOffset failedAtUtc)
{
Target = target;
Diagnostic = diagnostic;
FailedAtUtc = failedAtUtc;
}
public EveUnitySceneNavigationTarget Target { get; }
public string Diagnostic { get; }
public DateTimeOffset FailedAtUtc { get; }
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
#nullable enable
@ -234,6 +235,43 @@ namespace GameCult.Eve.UnityScene
event Action<EveUnitySceneCommandReceipt> ReceiptAvailable;
}
public interface IEveUnityNavigableProvider
{
Task NavigateAsync(EveUnitySceneNavigationTarget target);
void CommitNavigation();
void FinalizeNavigation();
void RollbackNavigation();
}
public sealed class EveUnitySceneNavigationTarget
{
public EveUnitySceneNavigationTarget(
string verseId,
string providerId,
string surfaceId,
string surfaceKind,
IReadOnlyList<string>? rendezvousEndpoints = null,
string authorityRuntimeId = "")
{
VerseId = verseId ?? "";
AuthorityRuntimeId = authorityRuntimeId ?? "";
ProviderId = providerId ?? "";
SurfaceId = surfaceId ?? "";
SurfaceKind = surfaceKind ?? "";
RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty<string>();
}
public string VerseId { get; }
public string AuthorityRuntimeId { get; }
public string ProviderId { get; }
public string SurfaceId { get; }
public string SurfaceKind { get; }
public IReadOnlyList<string> RendezvousEndpoints { get; }
}
public sealed class EveUnitySceneCommandReceipt
{
public EveUnitySceneCommandReceipt(
@ -248,7 +286,8 @@ namespace GameCult.Eve.UnityScene
string surfaceId = "",
string message = "",
DateTimeOffset? issuedAtUtc = null,
long sourceVersion = 0)
long sourceVersion = 0,
EveUnitySceneNavigationTarget? navigation = null)
{
ReceiptId = receiptId ?? "";
Command = command ?? "";
@ -262,6 +301,7 @@ namespace GameCult.Eve.UnityScene
Message = message ?? "";
IssuedAtUtc = issuedAtUtc;
SourceVersion = sourceVersion;
Navigation = navigation;
}
public string Schema { get; }
@ -288,6 +328,8 @@ namespace GameCult.Eve.UnityScene
public long SourceVersion { get; }
public EveUnitySceneNavigationTarget? Navigation { get; }
public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal);
public bool ShouldRefreshProviderSurface =>

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

@ -30,18 +30,7 @@ namespace GameCult.Eve.UnityScene
AttachToDocument();
return;
}
_document = GetComponent<UIDocument>();
if (_document == null)
{
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;
_document = gameObject.AddComponent<UIDocument>();
_document.panelSettings = panel;
_document.sortingOrder = 100;
}
_document = EveUnityUiDocumentRuntime.Ensure(gameObject);
_root = new VisualElement();
Build(_root);
AttachToDocument();

View file

@ -0,0 +1,39 @@
using System;
using UnityEngine;
using UnityEngine.UIElements;
#nullable enable
namespace GameCult.Eve.UnityScene
{
internal static class EveUnityUiDocumentRuntime
{
private const string ThemeResource = "EveUnity/UnityDefaultRuntimeTheme";
public static UIDocument Ensure(GameObject host)
{
var document = host.GetComponent<UIDocument>();
if (document != null) return document;
var panel = host.transform.parent?.GetComponentInParent<UIDocument>()?.panelSettings;
if (panel == null)
{
var theme = Resources.Load<ThemeStyleSheet>(ThemeResource);
if (theme == null)
throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing.");
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;
document.sortingOrder = 100;
return document;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 894b54214cb34993a7984f73cb45349f

View file

@ -56,16 +56,7 @@ namespace GameCult.Eve.UnityScene
private void EnsureDocument()
{
_document = GetComponent<UIDocument>();
if (_document != null) return;
var panel = ScriptableObject.CreateInstance<PanelSettings>();
panel.name = "Eve playable-world surface panel";
panel.scaleMode = PanelScaleMode.ScaleWithScreenSize;
panel.referenceResolution = new Vector2Int(1920, 1080);
panel.match = 0.5f;
_document = gameObject.AddComponent<UIDocument>();
_document.panelSettings = panel;
_document.sortingOrder = 100;
_document = EveUnityUiDocumentRuntime.Ensure(gameObject);
}
private void AttachToDocument()

View file

@ -0,0 +1 @@
@import url("unity-theme://default");

View file

@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: 0f30b970a2764f51ba064b032906259e
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0}
disableValidation: 0
unsupportedSelectorAction: 0

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,523 @@ namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityCultMeshAssetLookupTests
{
[Test]
public void ConnectCannotPerformRemoteWorkBeforeAsynchronousPreparation()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var elapsed = Stopwatch.StartNew();
var failure = Assert.Throws<InvalidOperationException>(() => transport.Connect());
StringAssert.Contains("Await PrepareAsync", failure!.Message);
Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100)));
}
[Test]
public async Task CommandOutboxCompletesOnlyAfterCanonicalReceipt()
{
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var sends = 0;
using var outbox = new EveUnityCultMeshCommandOutbox(
async (_, cancellationToken) =>
{
Interlocked.Increment(ref sends);
entered.TrySetResult(true);
await AwaitOrCancel(release.Task, cancellationToken);
},
_ => null,
retryDelay: TimeSpan.FromMilliseconds(250));
var elapsed = Stopwatch.StartNew();
outbox.Enqueue(Request("one-shot", "dock"));
Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100)));
await RequireCompletion(entered.Task);
Assert.That(outbox.PendingCount, Is.EqualTo(1));
release.TrySetResult(true);
await WaitUntilAsync(() => outbox.StateOf("one-shot") ==
EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt);
Assert.That(outbox.PendingCount, Is.EqualTo(1));
Assert.That(sends, Is.EqualTo(1));
Assert.That(outbox.Acknowledge("one-shot"), Is.True);
Assert.That(outbox.Acknowledge("one-shot"), Is.False);
await WaitUntilAsync(() => outbox.PendingCount == 0);
}
[Test]
public async Task CommandOutboxCoalescesBlockedContinuousInputToTheLatestValue()
{
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var sent = new List<string>();
using var outbox = new EveUnityCultMeshCommandOutbox(
async (request, cancellationToken) =>
{
lock (sent) sent.Add(request.CommandId);
entered.TrySetResult(true);
await AwaitOrCancel(release.Task, cancellationToken);
},
request => request.PayloadFields["commandId"] + "\u001f" + request.PayloadFields["entityId"],
retryDelay: TimeSpan.FromMilliseconds(250));
outbox.Enqueue(Request("move-0", "move"));
await RequireCompletion(entered.Task);
for (var index = 1; index <= 1000; index++)
outbox.Enqueue(Request("move-" + index, "move"));
Assert.That(outbox.PendingCount, Is.LessThanOrEqualTo(2));
release.TrySetResult(true);
await WaitUntilAsync(() => outbox.StateOf("move-1000") ==
EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt);
Assert.That(outbox.Acknowledge("move-1000"), Is.True);
await WaitUntilAsync(() => outbox.PendingCount == 0);
lock (sent)
{
CollectionAssert.AreEqual(new[] { "move-0", "move-1000" }, sent);
}
}
[Test]
public async Task CommandOutboxRetriesSameIdWithoutLettingPoisonCommandStarveLaterWork()
{
var attempts = new List<string>();
using var outbox = new EveUnityCultMeshCommandOutbox(
(request, _) =>
{
lock (attempts) attempts.Add(request.CommandId);
if (request.CommandId == "poison") throw new IOException("route unavailable");
return Task.CompletedTask;
},
_ => null,
retryDelay: TimeSpan.FromMilliseconds(20),
maximumRetryDelay: TimeSpan.FromMilliseconds(40));
outbox.Enqueue(Request("poison", "dock"));
outbox.Enqueue(Request("healthy", "undock"));
await WaitUntilAsync(() => outbox.StateOf("healthy") ==
EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt);
Assert.That(outbox.Acknowledge("healthy"), Is.True);
lock (attempts)
{
Assert.That(attempts.IndexOf("healthy"), Is.GreaterThanOrEqualTo(0));
Assert.That(attempts.FindAll(value => value == "poison"), Is.Not.Empty);
}
Assert.That(outbox.PendingCount, Is.EqualTo(1));
}
[Test]
public async Task CommandOutboxRetransmitsUnacknowledgedCommandWithOriginalId()
{
var attempts = 0;
using var outbox = new EveUnityCultMeshCommandOutbox(
(request, _) =>
{
Assert.That(request.CommandId, Is.EqualTo("retry-me"));
Interlocked.Increment(ref attempts);
return Task.CompletedTask;
},
_ => null,
retryDelay: TimeSpan.FromMilliseconds(20),
maximumRetryDelay: TimeSpan.FromMilliseconds(20));
outbox.Enqueue(Request("retry-me", "dock"));
await WaitUntilAsync(() => Volatile.Read(ref attempts) >= 2);
Assert.That(outbox.PendingCount, Is.EqualTo(1));
Assert.That(outbox.Acknowledge("retry-me"), Is.True);
await WaitUntilAsync(() => outbox.PendingCount == 0);
}
[Test]
public void PendingReceiptIsObservedWithoutRetiringTheCommand()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var request = Request("launch-1", "launch");
var pendingField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_pendingCommands",
BindingFlags.NonPublic | BindingFlags.Instance);
var publish = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"PublishReceipt",
BindingFlags.NonPublic | BindingFlags.Instance);
Assert.That(pendingField, Is.Not.Null);
Assert.That(publish, Is.Not.Null);
var pending = (IDictionary<string, EveSurfaceCommandRequest>)pendingField!.GetValue(transport)!;
pending.Add(request.CommandId, request);
var observed = new List<EveUnitySceneCommandReceipt>();
transport.CommandReceiptAvailable += observed.Add;
publish!.Invoke(transport, new object[] { Receipt(request, "pending", "receipt-pending") });
Assert.That(pending.ContainsKey(request.CommandId), Is.True);
Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending" }));
publish.Invoke(transport, new object[]
{
Receipt(request, "accepted", "receipt-accepted", sourceVersion: 1000)
});
Assert.That(pending.ContainsKey(request.CommandId), Is.False);
Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending", "accepted" }));
}
[Test]
public void TerminalReceiptWaitsForItsPresentationGenerationAndFreezesTheOldSurface()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var request = Request("select-verse-1", "selectVerse");
var type = typeof(EveUnityCultMeshLiveProviderTransport);
var pending = (IDictionary<string, EveSurfaceCommandRequest>)type.GetField(
"_pendingCommands",
BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(transport)!;
var publish = type.GetMethod("PublishReceipt", BindingFlags.NonPublic | BindingFlags.Instance)!;
var complete = type.GetMethod(
"CompletePresentationTransition",
BindingFlags.NonPublic | BindingFlags.Instance)!;
type.GetField("_bootstrapped", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(transport, true);
complete.Invoke(transport, new object[] { 1L });
pending.Add(request.CommandId, request);
var observed = new List<EveUnitySceneCommandReceipt>();
transport.CommandReceiptAvailable += observed.Add;
using var outbox = new EveUnityCultMeshCommandOutbox(
(_, _) => Task.CompletedTask,
_ => null,
retryDelay: TimeSpan.FromSeconds(30),
maximumRetryDelay: TimeSpan.FromSeconds(30));
type.GetField("_commandOutbox", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(transport, outbox);
outbox.Enqueue(request);
publish.Invoke(transport, new object[]
{
Receipt(
request,
"accepted",
"receipt-accepted",
sourceVersion: 1000,
presentationSurfaceVersion: 7)
});
Assert.That(pending.ContainsKey(request.CommandId), Is.True);
Assert.That(outbox.PendingCount, Is.Zero);
Assert.That(observed, Is.Empty);
type.GetProperty("CurrentSurfaceDocument")!.SetValue(transport, SceneSurface(100));
var blocked = Assert.Throws<InvalidOperationException>(() =>
transport.SubmitCommand(Request("stale-launch", "launch")));
StringAssert.Contains("read-only", blocked!.Message);
complete.Invoke(transport, new object[] { 7L });
Assert.That(pending.ContainsKey(request.CommandId), Is.False);
Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "accepted" }));
}
[Test]
public void ReceiptForAnotherInvocationCannotRetireTheCommand()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var request = Request("launch-1", "launch");
var pendingField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_pendingCommands",
BindingFlags.NonPublic | BindingFlags.Instance);
var publish = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"PublishReceipt",
BindingFlags.NonPublic | BindingFlags.Instance);
var pending = (IDictionary<string, EveSurfaceCommandRequest>)pendingField!.GetValue(transport)!;
pending.Add(request.CommandId, request);
var observed = new List<EveUnitySceneCommandReceipt>();
transport.CommandReceiptAvailable += observed.Add;
var collision = new EveCommandReceiptDocument(
"receipt-collision",
request.CommandId,
request.Command,
"accepted",
"AetheriaEve",
"test.runtime",
request.ProviderId,
request.SurfaceId,
"",
DateTimeOffset.UtcNow.ToString("O"),
1,
invocationHash: EveCommandInvocationHash.Compute(Request(request.CommandId, "another-payload")));
publish!.Invoke(transport, new object[] { collision });
Assert.That(pending.ContainsKey(request.CommandId), Is.True);
Assert.That(observed, Is.Empty);
}
[TestCase("provider", "other.provider")]
[TestCase("surface", "other.surface")]
[TestCase("authority", "other.runtime")]
[TestCase("state", "mysterious")]
public void UntrustedReceiptCannotRetireOrPublishTheCommand(string field, string value)
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var request = Request("launch-untrusted", "launch");
var type = typeof(EveUnityCultMeshLiveProviderTransport);
var pending = (IDictionary<string, EveSurfaceCommandRequest>)type.GetField(
"_pendingCommands",
BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(transport)!;
var publish = type.GetMethod("PublishReceipt", BindingFlags.NonPublic | BindingFlags.Instance)!;
pending.Add(request.CommandId, request);
var observed = new List<EveUnitySceneCommandReceipt>();
transport.CommandReceiptAvailable += observed.Add;
var providerId = field == "provider" ? value : request.ProviderId;
var surfaceId = field == "surface" ? value : request.SurfaceId;
var authority = field == "authority" ? value : "test.runtime";
var state = field == "state" ? value : "accepted";
var receipt = new EveCommandReceiptDocument(
"receipt-untrusted-" + field,
request.CommandId,
request.Command,
state,
"AetheriaEve",
authority,
providerId,
surfaceId,
"",
DateTimeOffset.UtcNow.ToString("O"),
1,
invocationHash: EveCommandInvocationHash.Compute(request));
publish.Invoke(transport, new object[] { receipt });
Assert.That(pending.ContainsKey(request.CommandId), Is.True);
Assert.That(observed, Is.Empty);
}
[Test]
public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute()
{
var selection = new EveUnityCultMeshProviderSelection(
"cultnet+tcp://odin.example:3075",
"aetheria.public",
"aetheria.public",
"aetheria.daemon",
"aetheria.pilot",
"interactive-world");
Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075"));
Assert.That(selection.VerseId, Is.EqualTo("aetheria.public"));
Assert.That(selection.AuthorityRuntimeId, Is.EqualTo("aetheria.public"));
Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null);
}
[Test]
public void NavigationAuthorityCannotBeReplacedByAlphabeticallyEarlierPeer()
{
var target = new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"aetheria.daemon",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3075" },
"z-owner");
var eligible = EveUnityCultMeshProviderDiscovery.EligibleAuthorityRuntimeIds(
new[] { "a-decoy", "z-owner" },
target.AuthorityRuntimeId);
Assert.That(target.AuthorityRuntimeId, Is.EqualTo("z-owner"));
CollectionAssert.AreEqual(new[] { "z-owner" }, eligible);
}
private static EveSurfaceCommandRequest Request(string id, string command) => new EveSurfaceCommandRequest(
"test.provider",
"test.surface",
CultMesh.OperationInvocation("test.command", idempotencyKey: id),
CultMesh.OperationPayload(new Dictionary<string, string>
{
["commandId"] = command,
["entityId"] = "ship"
}),
DateTimeOffset.UtcNow,
"test-client");
private static EveCommandReceiptDocument Receipt(
EveSurfaceCommandRequest request,
string state,
string receiptId,
long sourceVersion = 0,
long presentationSurfaceVersion = 0) => new EveCommandReceiptDocument(
receiptId,
request.CommandId,
request.Command,
state,
"AetheriaEve",
"test.runtime",
request.ProviderId,
request.SurfaceId,
"",
DateTimeOffset.UtcNow.ToString("O"),
sourceVersion,
invocationHash: EveCommandInvocationHash.Compute(request),
presentationSurfaceVersion: presentationSurfaceVersion);
[Test]
public void FailedNewestCandidateCanRetryWithoutAllowingAnOlderSurface()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var begin = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"TryBeginBaseSurfaceCandidate",
BindingFlags.NonPublic | BindingFlags.Instance)!;
var retire = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"RetireFailedBaseSurfaceCandidate",
BindingFlags.NonPublic | BindingFlags.Instance)!;
var first = new object[] { 10L, -1L };
var older = new object[] { 7L, -1L };
var duplicate = new object[] { 10L, -1L };
Assert.That(begin.Invoke(transport, first), Is.True);
Assert.That(begin.Invoke(transport, older), Is.False);
Assert.That(begin.Invoke(transport, duplicate), Is.False);
retire.Invoke(transport, new[] { first[0], first[1] });
var retry = new object[] { 10L, -1L };
Assert.That(begin.Invoke(transport, retry), Is.True);
Assert.That((long)retry[1], Is.GreaterThan((long)first[1]));
Assert.That(begin.Invoke(transport, new object[] { 9L, -1L }), Is.False);
}
[Test]
public async Task FailedAssetPreparationRunsBoundedRetryPolicyAndRetiresCandidate()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var type = typeof(EveUnityCultMeshLiveProviderTransport);
var begin = type.GetMethod(
"TryBeginBaseSurfaceCandidate",
BindingFlags.NonPublic | BindingFlags.Instance)!;
var refresh = type.GetMethod(
"RefreshAssetsForSurfaceAsync",
BindingFlags.NonPublic | BindingFlags.Instance)!;
var attempts = type.GetField(
"_lastAssetPresentationAttemptCount",
BindingFlags.NonPublic | BindingFlags.Instance)!;
var first = new object[] { 10L, -1L };
Assert.That(begin.Invoke(transport, first), Is.True);
var task = (Task)refresh.Invoke(transport, new object[]
{
SceneSurface(10).SurfaceDocument,
CreateAssetSource("test.verse", "test.runtime", "test.provider", "catalog", 41),
(long)first[1],
CancellationToken.None
})!;
await task;
Assert.That(attempts.GetValue(transport), Is.EqualTo(3));
Assert.That(begin.Invoke(transport, new object[] { 10L, -1L }), Is.True);
}
private static EveUnitySceneProviderSurfaceDocument SceneSurface(long version)
{
var surface = new EveSurfaceDocument(
"test.provider",
"",
"",
version,
"",
new EveSurfaceTree(
"test.surface",
new EveSurfaceComponent(
"root",
"surface",
new Dictionary<string, string>(),
Array.Empty<EveSurfaceComponent>()),
Array.Empty<EveStyleToken>()),
Array.Empty<EveCommandTemplate>());
return new EveUnitySceneProviderSurfaceDocument(
surface,
new EveUnitySceneProviderSurfaceAdvertisement(
"test.surface",
"interactive-world",
new EveUnitySceneWorldInteraction("", "", EveCommandReceiptDocument.SchemaId, "")),
"test:surface",
version);
}
private static async Task AwaitOrCancel(Task task, CancellationToken cancellationToken)
{
var cancelled = Task.Delay(Timeout.Infinite, cancellationToken);
if (await Task.WhenAny(task, cancelled) != task)
cancellationToken.ThrowIfCancellationRequested();
await task;
}
private static async Task RequireCompletion(Task task)
{
if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))) != task)
Assert.Fail("Timed out waiting for asynchronous command delivery.");
await task;
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(2);
while (!predicate() && DateTimeOffset.UtcNow < deadline)
await Task.Delay(10);
Assert.That(predicate(), Is.True, "Timed out waiting for the command outbox to drain.");
}
[Test]
public void LiveTransportOwnsOneCultMeshClientAndNoDirectSnapshotReplica()
{
var fields = typeof(EveUnityCultMeshLiveProviderTransport)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
var names = Array.ConvertAll(fields, field => field.Name);
CollectionAssert.Contains(names, "_meshClient");
CollectionAssert.DoesNotContain(names, "_node");
CollectionAssert.DoesNotContain(names, "_snapshot");
CollectionAssert.DoesNotContain(names, "_networkRegistry");
CollectionAssert.DoesNotContain(names, "_replicaShard");
CollectionAssert.DoesNotContain(names, "_subscriptions");
CollectionAssert.DoesNotContain(names, "_entitySubscriptions");
}
[Test]
public void UnavailableTcpRendezvousIsReportedAsRetryablePreparationFailure()
{
@ -125,28 +646,16 @@ namespace GameCult.Eve.UnityScene.Tests
}
[Test]
public void PublishingCatalogRegistersBundlesWithoutMaterializingThem()
public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem()
{
var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"CurrentBundlePlatform",
BindingFlags.Static | BindingFlags.NonPublic);
var publishMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"PublishAssetCatalog",
BindingFlags.Instance | BindingFlags.NonPublic);
var bundlesField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_assetBundles",
BindingFlags.Instance | BindingFlags.NonPublic);
var selectionsField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_assetSelections",
BindingFlags.Instance | BindingFlags.NonPublic);
var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"ConfigureAssetCatalog",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(platformMethod, Is.Not.Null);
Assert.That(publishMethod, Is.Not.Null);
using var transport = new EveUnityCultMeshLiveProviderTransport(
Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"),
"cultnet+tcp://127.0.0.1:1",
"provider",
"surface");
Assert.That(configureMethod, Is.Not.Null);
var variant = new EveAssetVariant(
"unity-scene",
(string)platformMethod!.Invoke(null, Array.Empty<object>())!,
@ -161,11 +670,229 @@ namespace GameCult.Eve.UnityScene.Tests
1,
DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) });
var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1);
publishMethod!.Invoke(transport, new object[] { catalog });
configureMethod!.Invoke(null, new[] { candidate, catalog });
Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero);
Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).Count, Is.EqualTo(1));
Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero);
Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1));
((IDisposable)candidate).Dispose();
}
[Test]
public void FailedAssetCandidatePreservesCommittedGeneration()
{
var transportType = typeof(EveUnityCultMeshLiveProviderTransport);
var generationField = transportType.GetField(
"_assetGeneration",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var configureMethod = transportType.GetMethod(
"ConfigureAssetCatalog",
BindingFlags.Static | BindingFlags.NonPublic)!;
using var transport = new EveUnityCultMeshLiveProviderTransport(
Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"),
"cultnet+tcp://127.0.0.1:1",
"verse-a",
"authority-a",
"provider-a",
"surface-a");
var initial = (IDisposable)generationField.GetValue(transport)!;
var committed = CreateAssetGeneration(
"verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 1);
var prefab = new GameObject("committed-a");
GenerationDictionary(committed, "Prefabs")["prefab.a"] = prefab;
initial.Dispose();
generationField.SetValue(transport, committed);
var invalidVariant = new EveAssetVariant(
"browser",
"web",
"url",
"https://example.invalid/a",
"sha256:00",
1,
"prefab.a");
var invalidCatalog = new EveAssetCatalogDocument(
"provider-b",
"catalog-b",
2,
DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) });
var rejected = CreateAssetGeneration(
"verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1);
var error = Assert.Throws<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,9 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Collections;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Eve.PluginFields;
using GameCult.Eve.Surface;
@ -11,6 +14,8 @@ using NUnit.Framework;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.UIElements;
using UnityEngine.TestTools;
#nullable enable
@ -24,9 +29,11 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.DoesNotThrow(() =>
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-replica.cc",
"test-cache",
"cultnet://127.0.0.1:3075",
"aetheria",
"aetheria",
"aetheria",
"aetheria.pilot");
});
}
@ -58,9 +65,11 @@ namespace GameCult.Eve.UnityScene.Tests
public void LiveTransportReusesStableLayoutForNewBodyPublications()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-replica.cc",
"test-cache",
"cultnet://127.0.0.1:3075",
"aetheria",
"aetheria",
"aetheria",
"aetheria.pilot");
var view = EntityLeaseDocument();
var publication = BodyPublication(view, view.Sequence + 1);
@ -92,9 +101,11 @@ namespace GameCult.Eve.UnityScene.Tests
public void LiveTransportPairsBodyPublicationThatArrivesBeforeItsLayout()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-replica.cc",
"test-cache",
"cultnet://127.0.0.1:3075",
"aetheria",
"aetheria",
"aetheria",
"aetheria.pilot");
var view = EntityLeaseDocument();
var publication = BodyPublication(view, view.Sequence);
@ -120,9 +131,11 @@ namespace GameCult.Eve.UnityScene.Tests
public void LiveTransportKeepsOnlyLatestPendingRealtimeGenerationPerBody()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-replica.cc",
"test-cache",
"cultnet://127.0.0.1:3075",
"provider",
"provider",
"provider",
"provider.pilot");
var enqueue = typeof(EveUnityCultMeshLiveProviderTransport)
.GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!;
@ -149,9 +162,11 @@ namespace GameCult.Eve.UnityScene.Tests
public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-replica.cc",
"test-cache",
"cultnet://127.0.0.1:3075",
"provider",
"provider",
"provider",
"provider.pilot");
var view = EntityLeaseDocument();
view.Sequence = 0;
@ -230,14 +245,20 @@ namespace GameCult.Eve.UnityScene.Tests
}
[Test]
public void LiveTransportDoesNotRegisterBodyBytesAsSnapshotDocuments()
public void LiveTransportHasNoSnapshotWireRegistryForBodyBytes()
{
var wireTypes = (Type[])typeof(EveUnityCultMeshLiveProviderTransport)
.GetField("WireDocumentTypes", BindingFlags.NonPublic | BindingFlags.Static)!
.GetValue(null)!;
var transport = typeof(EveUnityCultMeshLiveProviderTransport);
var wireRegistry = transport.GetField(
"WireDocumentTypes",
BindingFlags.NonPublic | BindingFlags.Static);
var fieldTypes = transport
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
.Select(field => field.FieldType)
.ToArray();
Assert.That(wireTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False);
Assert.That(wireTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False);
Assert.That(wireRegistry, Is.Null);
Assert.That(fieldTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False);
Assert.That(fieldTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False);
}
[Test]
@ -561,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);
@ -900,11 +925,19 @@ namespace GameCult.Eve.UnityScene.Tests
"aetheria.daemon.move_intent",
"accepted",
"Aetheria",
"provider-owned-daemon"));
"provider-owned-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"aetheria",
"aetheria.pilot",
"interactive-world")));
Assert.That(runtime.LastReceipt, Is.Not.Null);
Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True);
Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True);
Assert.That(runtime.LastReceipt.Navigation, Is.Not.Null);
Assert.That(runtime.LastReceipt.Navigation!.VerseId, Is.EqualTo("gamecult.aetheria"));
Assert.That(runtime.LastReceipt.Navigation.SurfaceId, Is.EqualTo("aetheria.pilot"));
}
[Test]
@ -1100,6 +1133,7 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(hostObject.GetComponent<EveUnityPlayableWorldCameraRig>(), Is.Not.Null);
Assert.That(hostObject.GetComponent<EveUnityUiToolkitOverlay>(), Is.Not.Null);
Assert.That(hostObject.GetComponent<EveUnityUiToolkitOverlay>().Root, Is.Not.Null);
Assert.That(hostObject.GetComponent<UIDocument>().panelSettings.themeStyleSheet, Is.Not.Null);
Assert.That(bootstrap.CameraTransform, Is.Not.Null);
Assert.That(provider.RefreshCount, Is.EqualTo(1));
@ -1121,6 +1155,204 @@ namespace GameCult.Eve.UnityScene.Tests
}
}
[UnityTest]
public IEnumerator PlayableWorldClientBootstrapFollowsProviderOwnedReceiptNavigation()
{
var hostObject = new GameObject("generic-eve-navigation-client");
try
{
var provider = hostObject.AddComponent<FakePlayableWorldProviderComponent>();
provider.Set(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://aetheria/assets/manifest",
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"));
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
bootstrap.ConfigureProvider(provider);
bootstrap.Mount();
var originalHost = bootstrap.Host;
var originalRoot = bootstrap.SceneRoot!;
provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf;
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
"receipt:launch",
"aetheria.hangar.launch",
"launch",
"accepted",
"Aetheria",
"commander-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"aetheria.daemon",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3076" })));
yield return null;
yield return null;
Assert.That(provider.LastNavigation, Is.Not.Null);
Assert.That(provider.LastNavigation!.VerseId, Is.EqualTo("gamecult.aetheria"));
Assert.That(provider.LastNavigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.example:3076" }));
Assert.That(bootstrap.Host, Is.Not.SameAs(originalHost));
Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1));
Assert.That(provider.CommitNavigationCount, Is.EqualTo(1));
Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(1));
Assert.That(provider.RollbackNavigationCount, Is.Zero);
Assert.That(provider.PreviousPresentationActiveDuringCommit, Is.False);
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
"receipt:launch-again",
"aetheria.hangar.launch",
"launch",
"accepted",
"Aetheria",
"commander-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"aetheria.daemon",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3076" })));
yield return null;
yield return null;
Assert.That(provider.CommitNavigationCount, Is.EqualTo(2));
Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(2));
Assert.That(provider.ActiveConnectionLeases, Is.EqualTo(1),
"disposing superseded presentations must leave the committed Verse connected");
Assert.That(hostObject.transform.Cast<Transform>()
.Count(child => child.name == "Eve Unity Candidate Presentation"), Is.EqualTo(1));
}
finally
{
UnityEngine.Object.DestroyImmediate(hostObject);
}
}
[UnityTest]
public IEnumerator PlayableWorldClientBootstrapKeepsMountedPresentationAfterNavigationMountFailure()
{
var hostObject = new GameObject("generic-eve-navigation-rollback-client");
try
{
var provider = hostObject.AddComponent<FakePlayableWorldProviderComponent>();
provider.Set(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://aetheria/assets/manifest",
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"));
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
bootstrap.ConfigureProvider(provider);
bootstrap.Mount();
var originalHost = bootstrap.Host;
var originalRuntime = originalHost!.Runtime;
var originalRoot = bootstrap.SceneRoot;
var originalPresentation = bootstrap.LastPresentation;
provider.FailNextNavigationMount = true;
LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted"));
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
"receipt:launch-failure",
"aetheria.hangar.launch",
"launch",
"accepted",
"Aetheria",
"commander-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"broken-provider",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3999" })));
yield return null;
yield return null;
Assert.That(provider.CommitNavigationCount, Is.Zero);
Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1));
Assert.That(bootstrap.LastNavigationFailure, Is.Not.Null);
Assert.That(bootstrap.Host, Is.SameAs(originalHost));
Assert.That(bootstrap.Host!.Runtime, Is.SameAs(originalRuntime));
Assert.That(bootstrap.SceneRoot, Is.SameAs(originalRoot));
Assert.That(bootstrap.LastPresentation, Is.SameAs(originalPresentation));
Assert.That(bootstrap.Host!.Runtime, Is.Not.Null);
Assert.That(bootstrap.Host.ActiveWorld, Is.Not.Null);
Assert.That(bootstrap.Host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard"));
Assert.That(bootstrap.Host.ConnectionEpoch, Is.EqualTo(1));
}
finally
{
UnityEngine.Object.DestroyImmediate(hostObject);
}
}
[UnityTest]
public IEnumerator PlayableWorldClientBootstrapRestoresProviderRouteBeforeReactivatingPreviousView()
{
var hostObject = new GameObject("generic-eve-navigation-order-client");
try
{
var provider = hostObject.AddComponent<FakePlayableWorldProviderComponent>();
provider.Set(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://aetheria/assets/manifest",
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"));
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
bootstrap.ConfigureProvider(provider);
bootstrap.Mount();
var originalRoot = bootstrap.SceneRoot!;
provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf;
provider.FailNextNavigationFinalize = true;
LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted"));
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
"receipt:launch-finalize-failure",
"aetheria.hangar.launch",
"launch",
"accepted",
"Aetheria",
"commander-daemon",
navigation: new EveUnitySceneNavigationTarget(
"gamecult.aetheria",
"candidate-provider",
"aetheria.pilot",
"interactive-world",
new[] { "cultnet+tcp://odin.example:3999" })));
yield return null;
yield return null;
Assert.That(provider.CommitNavigationCount, Is.EqualTo(1));
Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1));
Assert.That(provider.ActiveRoute, Is.EqualTo("original"));
Assert.That(provider.PreviousPresentationActiveDuringRollback, Is.False);
Assert.That(originalRoot.gameObject.activeSelf, Is.True);
Assert.That(bootstrap.Host!.Runtime, Is.Not.Null);
}
finally
{
UnityEngine.Object.DestroyImmediate(hostObject);
}
}
[Test]
public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector()
{
@ -2502,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,
@ -3206,12 +3442,38 @@ namespace GameCult.Eve.UnityScene.Tests
IEveUnitySceneCommandSink,
IEveUnitySceneCommandReceiptSource,
IEveUnityProviderRefreshSource,
IEveUnityInputCapabilitySource
IEveUnityInputCapabilitySource,
IEveUnitySceneProviderSurfaceDocumentConnection,
IEveUnityNavigableProvider
{
public List<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
public int RefreshCount { get; private set; }
public EveUnitySceneNavigationTarget? LastNavigation { get; private set; }
public int CommitNavigationCount { get; private set; }
public int FinalizeNavigationCount { get; private set; }
public int RollbackNavigationCount { get; private set; }
public bool FailNextNavigationMount { get; set; }
public bool FailNextNavigationFinalize { get; set; }
public int ActiveConnectionLeases { get; private set; }
public string ActiveRoute { get; private set; } = "original";
public Func<bool>? PreviousPresentationActive { get; set; }
public bool? PreviousPresentationActiveDuringCommit { get; private set; }
public bool? PreviousPresentationActiveDuringRollback { get; private set; }
private bool _failSurfaceRead;
public string SinkKind => "fake-provider-command-sink";
public string ManifestRef => CurrentDocument.ManifestRef;
@ -3233,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;
@ -3273,6 +3537,51 @@ namespace GameCult.Eve.UnityScene.Tests
{
ReceiptAvailable?.Invoke(receipt);
}
public Task NavigateAsync(EveUnitySceneNavigationTarget target)
{
LastNavigation = target;
_failSurfaceRead = FailNextNavigationMount;
FailNextNavigationMount = false;
return Task.CompletedTask;
}
public void Connect()
{
ActiveConnectionLeases++;
}
public void Disconnect()
{
if (ActiveConnectionLeases > 0)
ActiveConnectionLeases--;
}
public void CommitNavigation()
{
PreviousPresentationActiveDuringCommit = PreviousPresentationActive?.Invoke();
CommitNavigationCount++;
ActiveRoute = "candidate";
_failSurfaceRead = false;
}
public void FinalizeNavigation()
{
FinalizeNavigationCount++;
if (FailNextNavigationFinalize)
{
FailNextNavigationFinalize = false;
throw new InvalidOperationException("Deliberate provider finalization failure.");
}
}
public void RollbackNavigation()
{
RollbackNavigationCount++;
ActiveRoute = "original";
_failSurfaceRead = false;
PreviousPresentationActiveDuringRollback = PreviousPresentationActive?.Invoke();
}
}
}
}

View file

@ -160,14 +160,14 @@
"requiredPackageDependencies": [
{
"packageName": "org.gamecult.eve.surface",
"version": "0.2.4",
"version": "0.3.0",
"packageManager": "upm",
"ownerRepo": "EveUnity",
"purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents."
"ownerRepo": "Eve",
"purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents."
},
{
"packageName": "org.gamecult.eve.unity-uitoolkit",
"version": "0.1.2",
"version": "0.1.3",
"packageManager": "upm",
"ownerRepo": "EveUnity",
"purpose": "Generic UI Toolkit lowering for provider-owned Eve panes, metrics, progress, text, and controls."

View file

@ -1,14 +1,14 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.102",
"version": "0.3.134",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.43",
"org.gamecult.cultlib": "1.0.56",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.2.4",
"org.gamecult.eve.unity-uitoolkit": "0.1.2",
"org.gamecult.eve.surface": "0.3.6",
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.modules.particlesystem": "1.0.0"

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,10 +46,16 @@ namespace GameCult.Eve.UnityUIToolkit
element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}");
element.userData = component;
ApplyGeneratedLayout(element, component);
element.SetEnabled(IsEnabled(component));
if (IsHidden(component) || IsExternalProjectionRoot(component.Kind))
return element;
// Select options are semantic children consumed by the control itself.
// They are not separately lowered visual elements.
if (NormalizeKind(component.Kind) == "control.select")
return element;
foreach (var child in component.Children)
{
var loweredChild = LowerComponent(child, document, commandSink);
@ -114,6 +121,8 @@ namespace GameCult.Eve.UnityUIToolkit
return InventoryItem(component, document, commandSink);
case EveInventoryInteraction.DragSessionKind:
return InventoryDragSession(component);
case "control.select":
return Select(component, document, commandSink);
case "partition":
{
var element = new VisualElement();
@ -123,6 +132,8 @@ namespace GameCult.Eve.UnityUIToolkit
element.style.flexWrap = Wrap.Wrap;
return element;
}
case "scroll":
return new ScrollView(ScrollViewMode.Vertical);
case "pane":
case "modal":
case "card":
@ -266,6 +277,7 @@ namespace GameCult.Eve.UnityUIToolkit
return;
if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink))
_inventoryDragSource = null;
ClearInventoryPreview();
});
return grid;
}
@ -284,6 +296,7 @@ namespace GameCult.Eve.UnityUIToolkit
_inventoryDragSource = component;
_inventoryDragStart = evt.position;
_inventoryPointerMoved = false;
ClearInventoryPreview();
item.CapturePointer(evt.pointerId);
evt.StopPropagation();
});
@ -293,7 +306,15 @@ namespace GameCult.Eve.UnityUIToolkit
return;
var pointerPosition = new Vector2(evt.position.x, evt.position.y);
if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f)
{
_inventoryPointerMoved = true;
var picked = item.panel?.Pick(evt.position);
var targetElement = InventoryGridAncestor(picked);
if (targetElement?.userData is EveSurfaceComponent target)
ShowInventoryPreview(component, target, targetElement, evt.position);
else
ClearInventoryPreview();
}
});
item.RegisterCallback<PointerUpEvent>(evt =>
{
@ -308,6 +329,7 @@ namespace GameCult.Eve.UnityUIToolkit
TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink);
_inventoryDragSource = null;
_suppressInventoryClick = true;
ClearInventoryPreview();
}
evt.StopPropagation();
});
@ -399,6 +421,50 @@ namespace GameCult.Eve.UnityUIToolkit
return true;
}
private void ShowInventoryPreview(
EveSurfaceComponent source,
EveSurfaceComponent target,
VisualElement targetElement,
Vector2 panelPosition)
{
ClearInventoryPreview();
var local = targetElement.WorldToLocal(panelPosition);
var cellSize = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f));
var gap = Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f));
var pitch = cellSize + gap;
var x = Math.Max(0, (int)Math.Floor(local.x / Math.Max(1f, pitch)));
var y = Math.Max(0, (int)Math.Floor(local.y / Math.Max(1f, pitch)));
if (!EveInventoryInteraction.TryCreatePlacementPreview(source, target, x, y, out var preview) ||
preview == null)
return;
foreach (var cell in preview.Cells)
{
var element = new VisualElement { pickingMode = PickingMode.Ignore };
element.AddToClassList("eve-inventory-placement-preview");
element.AddToClassList(preview.IsValid
? "eve-inventory-placement-valid"
: "eve-inventory-placement-invalid");
element.style.position = Position.Absolute;
element.style.left = cell.X * pitch;
element.style.top = cell.Y * pitch;
element.style.width = cellSize;
element.style.height = cellSize;
element.style.backgroundColor = preview.IsValid
? new Color(0.2f, 0.9f, 0.65f, 0.34f)
: new Color(1f, 0.25f, 0.2f, 0.38f);
targetElement.Add(element);
_inventoryPreviewCells.Add(element);
}
}
private void ClearInventoryPreview()
{
foreach (var cell in _inventoryPreviewCells)
cell.RemoveFromHierarchy();
_inventoryPreviewCells.Clear();
}
private static int ParseInt(string value, int fallback) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
@ -448,10 +514,62 @@ namespace GameCult.Eve.UnityUIToolkit
foreach (var template in document.Commands)
{
if (string.Equals(template.Command, command, StringComparison.Ordinal))
return GameCult.Mesh.CultMesh.OperationInvocation(template.Operation);
return GameCult.Mesh.CultMesh.OperationInvocation(
template.Operation,
idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}");
}
return GameCult.Mesh.CultMesh.OperationInvocation(command);
return GameCult.Mesh.CultMesh.OperationInvocation(
command,
idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}");
}
private static VisualElement Select(
EveSurfaceComponent component,
EveSurfaceDocument document,
Action<EveSurfaceCommandRequest>? commandSink)
{
var options = component.Children
.Where(child => NormalizeKind(child.Kind) == "control.option")
.Select(child => new
{
Label = child.GetProp("label", child.GetProp("value")),
Value = child.GetProp("value")
})
.Where(option => !string.IsNullOrWhiteSpace(option.Value))
.ToArray();
var duplicateLabels = options
.GroupBy(option => option.Label, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToHashSet(StringComparer.Ordinal);
var labels = options
.Select(option => duplicateLabels.Contains(option.Label)
? $"{option.Label} [{option.Value}]"
: option.Label)
.ToList();
var selectedValue = component.GetProp("value");
var selectedIndex = Array.FindIndex(options, option =>
string.Equals(option.Value, selectedValue, StringComparison.Ordinal));
var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex));
field.RegisterValueChangedCallback(change =>
{
var index = field.index;
if (index < 0 || index >= options.Length)
return;
var option = options[index];
var payload = new Dictionary<string, string>(component.Props, StringComparer.Ordinal)
{
["value"] = option.Value
};
EmitCommand(
document,
component,
component.GetProp("command", component.GetProp("operationId")),
GameCult.Mesh.CultMesh.OperationPayload(payload),
commandSink);
});
return field;
}
private static Label TitleLabel(string text)
@ -549,6 +667,12 @@ namespace GameCult.Eve.UnityUIToolkit
element.style.alignItems = align;
if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify))
element.style.justifyContent = justify;
if (TryGet(layout, "flexGrow", out var flexGrow))
element.style.flexGrow = ParseFloat(flexGrow);
if (TryGet(layout, "flexShrink", out var flexShrink))
element.style.flexShrink = ParseFloat(flexShrink);
if (TryGet(layout, "flexBasis", out var flexBasis))
element.style.flexBasis = ParseLength(flexBasis);
if (TryGet(layout, "width", out var width))
element.style.width = ParseLength(width);
if (TryGet(layout, "minWidth", out var minWidth))
@ -625,6 +749,14 @@ namespace GameCult.Eve.UnityUIToolkit
return false;
}
private static bool IsEnabled(EveSurfaceComponent component)
{
if (ParseBool(component.GetProp("disabled")))
return false;
var enabled = component.GetProp("enabled");
return string.IsNullOrWhiteSpace(enabled) || ParseBool(enabled);
}
private static StyleLength ParseLength(string value)
{
value = FirstToken(value);

View file

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Eve.Surface;
using GameCult.Mesh;
using NUnit.Framework;
using UnityEditor;
using UnityEngine.UIElements;
#nullable enable
@ -11,6 +13,108 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
{
public sealed class EveUiToolkitSurfaceLowererTests
{
[Test]
public void SelectLowersProviderOptionsAndEmitsSelectedTypedValue()
{
EveSurfaceCommandRequest? emitted = null;
var select = Component(
"verse",
"control.select",
new Dictionary<string, string>
{
["label"] = "VERSE",
["value"] = "local",
["command"] = "eve.client.verse.select"
},
new[]
{
Component("local", "control.option", new Dictionary<string, string> { ["label"] = "Local", ["value"] = "local" }),
Component("hosted", "control.option", new Dictionary<string, string> { ["label"] = "Hosted", ["value"] = "hosted" })
});
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request);
var field = root.Q<DropdownField>();
var window = EditorWindow.CreateInstance<EditorWindow>();
try
{
window.rootVisualElement.Add(root);
window.Show();
Assert.That(field, Is.Not.Null);
Assert.That(field.choices, Is.EqualTo(new[] { "Local", "Hosted" }));
field.value = "Hosted";
Assert.That(emitted, Is.Not.Null);
Assert.That(emitted!.Command, Is.EqualTo("eve.client.verse.select"));
Assert.That(emitted.PayloadFields["value"], Is.EqualTo("hosted"));
}
finally
{
window.Close();
}
}
[Test]
public void SelectKeepsStableValuesWhenVerseLabelsCollide()
{
EveSurfaceCommandRequest? emitted = null;
var select = Component(
"verse",
"control.select",
new Dictionary<string, string>
{
["label"] = "VERSE",
["value"] = "verse:a",
["command"] = "eve.client.verse.select"
},
new[]
{
Component("a", "control.option", new Dictionary<string, string> { ["label"] = "Aetheria", ["value"] = "verse:a" }),
Component("b", "control.option", new Dictionary<string, string> { ["label"] = "Aetheria", ["value"] = "verse:b" })
});
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request);
var field = root.Q<DropdownField>();
var window = EditorWindow.CreateInstance<EditorWindow>();
try
{
window.rootVisualElement.Add(root);
window.Show();
Assert.That(field.choices, Is.EqualTo(new[] { "Aetheria [verse:a]", "Aetheria [verse:b]" }));
field.index = 1;
Assert.That(emitted, Is.Not.Null);
Assert.That(emitted!.PayloadFields["value"], Is.EqualTo("verse:b"));
}
finally
{
window.Close();
}
}
[Test]
public void SemanticEnabledStateAndMinMaxFlexLayoutAreLowered()
{
var button = new EveSurfaceComponent(
"launch",
"control.button",
new Dictionary<string, string> { ["label"] = "LAUNCH", ["disabled"] = "true" },
Array.Empty<EveSurfaceComponent>(),
Array.Empty<CultMeshStateBindingDescriptor>(),
Array.Empty<EveEmbeddedDocumentSlot>(),
new Dictionary<string, string>
{
["minWidth"] = "280",
["maxWidth"] = "360",
["flexGrow"] = "1"
});
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(button));
var lowered = root.Q<Button>();
Assert.That(lowered.enabledSelf, Is.False);
Assert.That(lowered.style.minWidth.value.value, Is.EqualTo(280f));
Assert.That(lowered.style.maxWidth.value.value, Is.EqualTo(360f));
Assert.That(lowered.style.flexGrow.value, Is.EqualTo(1f));
}
[Test]
public void DefaultOptionsExposeSaiNornAndTeXProjectionAdapters()
{
@ -204,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

@ -83,10 +83,10 @@
"requiredPackageDependencies": [
{
"packageName": "org.gamecult.eve.surface",
"version": "0.1.0",
"version": "0.3.0",
"packageManager": "upm",
"ownerRepo": "EveUnity",
"purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents."
"ownerRepo": "Eve",
"purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents."
}
]
},

View file

@ -1,11 +1,11 @@
{
"name": "org.gamecult.eve.unity-uitoolkit",
"displayName": "Eve UI Toolkit Lowering",
"version": "0.1.2",
"version": "0.1.9",
"unity": "2021.3",
"description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.",
"dependencies": {
"org.gamecult.eve.surface": "0.2.4"
"org.gamecult.eve.surface": "0.3.6"
},
"keywords": [
"Eve",

View file

@ -17,10 +17,10 @@ param(
)
$ErrorActionPreference = "Stop"
$expectedEveUnityCommit = "3632bfb3354065b78a90b921d7025c4c3fa92be4"
$expectedEveUnityCommit = "d1663b36d12b60ff5c7249135eff48edf6227ca9"
$expectedEveFieldsCommit = "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0"
$expectedEveUnityUiToolkitCommit = "4d0cbe0185bdc4fc65eb63503a7c5cb578539669"
$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 }
@ -40,7 +40,7 @@ $mapCapturePath = Join-Path $outputRoot "aetheria-daemon-map.png"
$factsPath = Join-Path $outputRoot "witness-facts.json"
$providerReadyPath = Join-Path $outputRoot "provider-ready.txt"
$witnessPath = Join-Path $outputRoot "runtime-witness.json"
$replicaPath = Join-Path $outputRoot "eve-unity-replica.cc"
$clientCachePath = Join-Path $outputRoot "eve-unity-cache"
$statePath = Join-Path $outputRoot "aetheria-witness-state.cc"
$providerBundleDirectory = Join-Path $AetheriaRoot "Build\EveAssets\StandaloneWindows64"
$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj"
@ -86,9 +86,7 @@ $witnessProfile = if ($observedCacheState -eq "cold") {
}
$witnessStartedAt = [DateTimeOffset]::UtcNow
foreach ($ephemeralPath in @(
$replicaPath,
"$replicaPath.records",
"$replicaPath.cultmesh",
$clientCachePath,
$statePath,
"$statePath.records",
"$statePath.cultmesh",
@ -211,7 +209,7 @@ $env:AETHERIA_TRACE_STARTUP_PHASES = "1"
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "cultnet+tcp://127.0.0.1:$Port"
$env:EVEUNITY_PROVIDER_ID = "aetheria"
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
$env:EVEUNITY_REPLICA_PATH = $replicaPath
$env:EVEUNITY_CACHE_DIRECTORY = $clientCachePath
$env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath
$env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH = $mapCapturePath
$env:EVEUNITY_DISABLE_AUTO_LAUNCHER = "1"
@ -474,6 +472,6 @@ try {
finally {
if ($null -ne $unity -and -not $unity.HasExited) { Stop-Process -Id $unity.Id -Force -ErrorAction SilentlyContinue }
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force }
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH, Env:EVEUNITY_DISABLE_AUTO_LAUNCHER, Env:EVEUNITY_ASSET_CACHE_PATH, Env:EVEUNITY_WITNESS_FACTS_PATH, Env:EVEUNITY_PROVIDER_READY_PATH, Env:EVEUNITY_WITNESS_PROFILE, Env:EVEUNITY_WITNESS_GAMEPLAY_SCENARIO -ErrorAction SilentlyContinue
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_CACHE_DIRECTORY, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_AETHERIA_MAP_CAPTURE_PATH, Env:EVEUNITY_DISABLE_AUTO_LAUNCHER, Env:EVEUNITY_ASSET_CACHE_PATH, Env:EVEUNITY_WITNESS_FACTS_PATH, Env:EVEUNITY_PROVIDER_READY_PATH, Env:EVEUNITY_WITNESS_PROFILE, Env:EVEUNITY_WITNESS_GAMEPLAY_SCENARIO -ErrorAction SilentlyContinue
Remove-Item Env:AETHERIA_TRACE_EVE_SNAPSHOTS, Env:AETHERIA_TRACE_CLIENT_TRANSPORT -ErrorAction SilentlyContinue
}

View file

@ -19,7 +19,7 @@ $daemonLog = Join-Path $outputRoot "daemon.log"
$importLog = Join-Path $outputRoot "import.log"
$importErrorLog = Join-Path $outputRoot "import.error.log"
$state = Join-Path $outputRoot "aetheria.cc"
$replica = Join-Path $outputRoot "eve-unity-replica.cc"
$clientCache = Join-Path $outputRoot "eve-unity-cache"
$assetCache = Join-Path $outputRoot "asset-cache"
$exe = Join-Path $project "Build\Windows\EveUnity.exe"
$importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj"
@ -32,7 +32,7 @@ if (Get-NetUDPEndpoint -LocalPort $Port -ErrorAction SilentlyContinue) {
throw "UDP port $Port is already occupied."
}
New-Item -ItemType Directory -Force $outputRoot, $assetCache | Out-Null
foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $replica, "$replica.records", "$replica.cultmesh")) {
foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $clientCache)) {
$resolved = [IO.Path]::GetFullPath($ephemeralPath)
if (-not $resolved.StartsWith([IO.Path]::GetFullPath($outputRoot), [StringComparison]::OrdinalIgnoreCase)) {
throw "Interactive cleanup escaped its artifact directory: $resolved"
@ -106,7 +106,7 @@ try {
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port"
$env:EVEUNITY_PROVIDER_ID = "aetheria"
$env:EVEUNITY_REPLICA_PATH = $replica
$env:EVEUNITY_CACHE_DIRECTORY = $clientCache
$env:EVEUNITY_ASSET_CACHE_PATH = $assetCache
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
$client = Start-Process $exe -ArgumentList "-force-d3d11" -PassThru
@ -117,5 +117,5 @@ try {
}
finally {
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process $daemon.Id -Force }
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_CACHE_DIRECTORY, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue
}

View file

@ -1,40 +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"
foreach ($required in @($UnityExe, $projectPath, $cultLibBuilder)) {
$obsoleteSurfacePackage = Join-Path $repoRoot "packages\org.gamecult.eve.surface"
$obsoleteSurfaceSource = @("package.json", "Runtime", "Editor") |
ForEach-Object { Join-Path $obsoleteSurfacePackage $_ } |
Where-Object { Test-Path -LiteralPath $_ }
if ($obsoleteSurfaceSource.Count -gt 0) {
throw "EveUnity must consume the Eve-owned surface package; duplicate source exists at $obsoleteSurfacePackage"
}
foreach ($required in @(
$UnityExe,
$projectPath,
$cultLibBuilder,
(Join-Path $EveRoot "packages\org.gamecult.eve.surface\package.json"),
(Join-Path $EvePluginsRoot "plugins\eve-plugin-fields\unity\org.gamecult.eve.plugin-fields\package.json")
)) {
if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity package-test path not found: $required" }
}
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
@ -62,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

View file

@ -1 +0,0 @@
!GameCult.Eve.Surface.Serialization.Tests.csproj

View file

@ -1,262 +0,0 @@
using System.Buffers;
using GameCult.Eve.Surface;
using GameCult.Mesh;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Eve.Surface.Tests;
[TestFixture]
public sealed class EveSurfaceSerializationCompatibilityTests
{
[Test]
public void EntitySoaV2SerializesLogicalLayoutWithoutTransportFields()
{
var document = new EveEntitySoaViewDocument
{
ProviderId = "provider",
ViewId = "entities",
BodySchemaId = "entity.slab.v1",
LayoutVersion = 2,
ProducerEpoch = 3,
Sequence = 4,
Capacity = 8,
FrameId = 91,
Buffers = new[] { new EveEntitySoaBuffer { BufferId = "entity-hot", ByteLength = 64 } },
Identities = new[]
{
new EveEntityIdentity
{
Index = 0,
EntityId = "player",
EntityKind = "ship",
Label = "Vanguard",
Faction = "alliance",
Selectable = true,
Controllable = true,
AssetRef = "cultmesh://assets/player"
}
}
};
var bytes = MessagePackSerializer.Serialize(document, Options);
var reader = new MessagePackReader(bytes);
var restored = MessagePackSerializer.Deserialize<EveEntitySoaViewDocument>(bytes, Options);
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(15));
Assert.That(restored.Schema, Is.EqualTo(EveEntitySoaViewDocument.SchemaId));
Assert.That(restored.Buffers[0].BufferId, Is.EqualTo("entity-hot"));
Assert.That(restored.FrameId, Is.EqualTo(91));
Assert.That(restored.Identities[0].Label, Is.EqualTo("Vanguard"));
Assert.That(restored.Identities[0].Faction, Is.EqualTo("alliance"));
Assert.That(restored.Identities[0].Selectable, Is.True);
Assert.That(restored.Identities[0].Controllable, Is.True);
Assert.That(restored.Identities[0].AssetRef, Is.EqualTo("cultmesh://assets/player"));
}
[Test]
public void EntitySoaContractsExposeNoTransportAuthorityFields()
{
var forbidden = new[] { "Backend", "Location", "CapabilityToken", "Synchronization", "SynchronizationMode", "Descriptor" };
var exposed = new[] { typeof(EveEntitySoaViewDocument), typeof(EveEntitySoaBuffer), typeof(EveEntitySoaColumn) }
.SelectMany(type => type.GetProperties())
.Select(property => property.Name);
Assert.That(exposed, Has.None.Matches<string>(name => forbidden.Contains(name, StringComparer.Ordinal)));
}
private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
[Test]
public void LegacySevenFieldSurfaceDocumentDeserializes()
{
var bytes = WriteLegacySurfaceDocument();
var document = MessagePackSerializer.Deserialize<EveSurfaceDocument>(bytes, Options);
Assert.Multiple(() =>
{
Assert.That(document.Type, Is.EqualTo(EveSurfaceDocument.DefaultType));
Assert.That(document.Schema, Is.EqualTo(EveSurfaceDocument.SchemaId));
Assert.That(document.ProviderId, Is.EqualTo("legacy-provider"));
Assert.That(document.ProviderKind, Is.EqualTo("legacy-kind"));
Assert.That(document.Title, Is.EqualTo("Legacy surface"));
Assert.That(document.Version, Is.EqualTo(17));
Assert.That(document.UpdatedAtUtc, Is.EqualTo("2026-07-13T12:00:00Z"));
Assert.That(document.Surface.Id, Is.EqualTo("legacy-surface"));
Assert.That(document.Commands, Has.Count.EqualTo(1));
Assert.That(document.Commands[0].Command, Is.EqualTo("legacy.execute"));
});
}
[Test]
public void LegacyDirectFiveStringCommandTemplateDeserializes()
{
var bytes = WriteLegacyCommandTemplate();
var command = MessagePackSerializer.Deserialize<EveCommandTemplate>(bytes, Options);
Assert.Multiple(() =>
{
Assert.That(command.Command, Is.EqualTo("legacy.execute"));
Assert.That(command.Label, Is.EqualTo("Execute legacy command"));
Assert.That(command.Operation.SchemaId, Is.EqualTo("gamecult.legacy.command.v1"));
Assert.That(command.OperationRecord.RouteKind, Is.EqualTo("cultmesh"));
Assert.That(command.OperationRecord.RouteDescription, Is.EqualTo("legacy.commands"));
Assert.That(command.Transport, Is.EqualTo("legacy.commands"));
});
}
[Test]
public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding()
{
var document = CreateCurrentDocument();
var bytes = MessagePackSerializer.Serialize(document, Options);
var reader = new MessagePackReader(bytes);
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9));
for (var field = 0; field < 8; field++)
reader.Skip();
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "commands collection");
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "command template wrapper");
Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Array), "structured operation binding");
var roundTrip = MessagePackSerializer.Deserialize<EveSurfaceDocument>(bytes, Options);
Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute"));
}
[Test]
public void LegacyExpandedProviderAdvertisementDeserializesAsCanonicalContract()
{
var document = MessagePackSerializer.Deserialize<EveProviderAdvertisementDocument>(
WriteLegacyProviderAdvertisement(),
Options);
Assert.Multiple(() =>
{
Assert.That(document.ProviderId, Is.EqualTo("aetheria"));
Assert.That(document.ServiceId, Is.EqualTo("aetheria-daemon"));
Assert.That(document.VerseId, Is.EqualTo("aetheria.local"));
Assert.That(document.Title, Is.EqualTo("Aetheria"));
Assert.That(document.CultMeshAddress, Is.EqualTo("cultmesh://aetheria.local/aetheria"));
Assert.That(document.Surfaces[0].SurfaceId, Is.EqualTo("aetheria.pilot"));
Assert.That(document.Surfaces[0].Schema, Is.EqualTo(EveSurfaceDocument.SchemaId));
Assert.That(document.Commands[0].Command, Is.EqualTo("aetheria.pilot.move"));
Assert.That(document.Commands[0].SurfaceId, Is.Empty);
});
}
[Test]
public void CurrentProviderAdvertisementSerializationWritesCanonicalThirteenFields()
{
var document = new EveProviderAdvertisementDocument(
"provider", "service", "verse", "Title", "game.runtime", "cultmesh://verse/provider",
"2026-07-13T14:00:00Z", new EveProviderFreshness("fresh", "2026-07-13T14:00:00Z", 15000),
new[] { EveSurfaceDocument.SchemaId }, Array.Empty<EveProviderWitness>(),
Array.Empty<EveAdvertisedSurface>(), Array.Empty<EveAdvertisedCommand>());
var bytes = MessagePackSerializer.Serialize(document, Options);
var reader = new MessagePackReader(bytes);
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(13));
Assert.That(MessagePackSerializer.Deserialize<EveProviderAdvertisementDocument>(bytes, Options).Title,
Is.EqualTo("Title"));
}
private static EveSurfaceDocument CreateCurrentDocument() => new(
"current-provider",
"current-kind",
"Current surface",
23,
"2026-07-13T13:00:00Z",
CreateSurfaceTree("current-surface"),
new[]
{
new EveCommandTemplate(new CultMeshOperationBindingRecord(
"current.execute",
"Execute current command",
"gamecult.current.command.v1",
"cultmesh",
"current.commands"))
});
private static EveSurfaceTree CreateSurfaceTree(string id) => new(
id,
new EveSurfaceComponent(
"root",
"column",
new Dictionary<string, string>(),
Array.Empty<EveSurfaceComponent>()),
Array.Empty<EveStyleToken>());
private static byte[] WriteLegacySurfaceDocument()
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(7);
writer.Write("legacy-provider");
writer.Write("legacy-kind");
writer.Write("Legacy surface");
writer.Write(17L);
writer.Write("2026-07-13T12:00:00Z");
writer.WriteRaw(MessagePackSerializer.Serialize(CreateSurfaceTree("legacy-surface"), Options));
writer.WriteArrayHeader(1);
writer.WriteRaw(WriteLegacyCommandTemplate());
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static byte[] WriteLegacyCommandTemplate()
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(5);
writer.Write("legacy.execute");
writer.Write("Execute legacy command");
writer.Write("gamecult.legacy.command.v1");
writer.Write("cultmesh");
writer.Write("legacy.commands");
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static byte[] WriteLegacyProviderAdvertisement()
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(16);
writer.Write(EveProviderAdvertisementDocument.SchemaId);
writer.Write("aetheria");
writer.Write("aetheria-daemon");
writer.Write("aetheria.local");
writer.Write("asgard");
writer.Write("aetheria");
writer.Write("aetheria.local/aetheria");
writer.Write("cultmesh://aetheria.local/aetheria");
writer.Write("Aetheria");
writer.Write("game.runtime");
writer.Write("2026-07-13T14:00:00Z");
writer.WriteArrayHeader(3);
writer.Write("fresh");
writer.Write("2026-07-13T14:00:00Z");
writer.Write(15000);
writer.WriteArrayHeader(1);
writer.Write(EveSurfaceDocument.SchemaId);
writer.WriteArrayHeader(0);
writer.WriteArrayHeader(1);
writer.WriteArrayHeader(5);
writer.Write(EveSurfaceDocument.SchemaId);
writer.Write("aetheria.pilot");
writer.Write("eve:surface:aetheria.pilot");
writer.Write("cultmesh");
writer.Write("available");
writer.WriteArrayHeader(1);
writer.WriteArrayHeader(3);
writer.Write("aetheria.pilot.move");
writer.Write("cultmesh");
writer.Write("Move the pilot ship");
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
}

View file

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<BaseOutputPath>obj\bin\</BaseOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
</ItemGroup>
</Project>