diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs new file mode 100644 index 0000000..61285ea --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GameCult.Eve.UnityScene.Tests")] diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs.meta new file mode 100644 index 0000000..db8b4c5 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 68e2f1a625b94fc39dcc44d33e0cf318 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 5055c02..c0e0f7e 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -55,6 +55,8 @@ namespace GameCult.Eve.UnityScene private readonly List _assetBodyLeases = new List(); private readonly Dictionary _renderChannelLayers = new Dictionary(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); + private readonly Dictionary _embeddedSurfaces = + new Dictionary(StringComparer.Ordinal); private CultMeshNode? _node; private CultMeshSnapshotSession? _snapshot; private CultNetDocumentRegistry? _networkRegistry; @@ -66,6 +68,7 @@ namespace GameCult.Eve.UnityScene private CultMeshVerifiedBodyMappingBroker? _assetBodyMappings; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; + private EveSurfaceDocument? _baseSurface; private CultMeshBodyPublicationResolver? _bodyResolver; private EveEntitySoaViewDocument? _latestEntityLayout; private CultMeshBodyPublicationDocument? _latestEntityPublication; @@ -215,8 +218,13 @@ namespace GameCult.Eve.UnityScene PublishEntityView(entityGeneration.View, entityGeneration.Publication); else if (document is EveFieldsSplatsDocument fields) FieldsSplatsAvailable?.Invoke(fields); - else if (document is EveSurfaceDocument surface) - PublishSurface(surface); + else if (document is PendingSurfaceDocument surface) + { + if (string.Equals(surface.RecordKey, _advertisedSurface?.RecordRef, StringComparison.Ordinal)) + PublishBaseSurface(surface.Document); + else + PublishEmbeddedSurface(surface.RecordKey, surface.Document); + } else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; else if (document is EveAssetCatalogDocument assetCatalog) @@ -392,6 +400,8 @@ namespace GameCult.Eve.UnityScene _assetBodyMappings = null; _networkRegistry = null; _replicaShard = null; + _baseSurface = null; + _embeddedSurfaces.Clear(); _pendingCommandIds.Clear(); _publishedReceiptIds.Clear(); } @@ -723,11 +733,29 @@ namespace GameCult.Eve.UnityScene .GetResult() .FirstOrDefault() ?? throw new InvalidOperationException("The advertised Eve surface record was not available."); - PublishSurface(surface); + PublishBaseSurface(surface); } - private void PublishSurface(EveSurfaceDocument surface) + private void PublishBaseSurface(EveSurfaceDocument surface) { + _baseSurface = surface ?? throw new ArgumentNullException(nameof(surface)); + PublishComposedSurface(); + EnsureLiveSubscriptions(); + } + + private void PublishEmbeddedSurface(string recordKey, EveSurfaceDocument surface) + { + if (string.IsNullOrWhiteSpace(recordKey)) + throw new InvalidOperationException("An embedded Eve surface update has no record identity."); + _embeddedSurfaces[recordKey] = surface ?? throw new ArgumentNullException(nameof(surface)); + PublishComposedSurface(); + } + + private void PublishComposedSurface() + { + var surface = ComposeSurface( + _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable."), + _embeddedSurfaces); var interaction = RequireWorldInteraction(); CurrentSurfaceDocument = new EveUnitySceneProviderSurfaceDocument( surface, @@ -742,7 +770,50 @@ namespace GameCult.Eve.UnityScene _advertisedSurface.RecordRef, surface.Version); SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); - EnsureLiveSubscriptions(); + } + + internal static EveSurfaceDocument ComposeSurface( + EveSurfaceDocument surface, + IReadOnlyDictionary embeddedSurfaces) + { + var root = ComposeComponent(surface.Surface.Root, embeddedSurfaces); + var version = embeddedSurfaces.Values.Select(value => value.Version) + .DefaultIfEmpty(surface.Version) + .Append(surface.Version) + .Max(); + return new EveSurfaceDocument( + surface.Type, + surface.Schema, + surface.ProviderId, + surface.ProviderKind, + surface.Title, + version, + surface.UpdatedAtUtc, + new EveSurfaceTree(surface.Surface.Id, root, surface.Surface.Styles), + surface.Commands); + } + + private static EveSurfaceComponent ComposeComponent( + EveSurfaceComponent component, + IReadOnlyDictionary embeddedSurfaces) + { + var children = component.Children.Select(child => ComposeComponent(child, embeddedSurfaces)).ToList(); + foreach (var slot in component.EmbeddedDocuments ?? Array.Empty()) + { + if (!string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal) || + !embeddedSurfaces.TryGetValue(slot.DocumentId, out var embedded)) + continue; + children.Add(ComposeComponent(embedded.Surface.Root, embeddedSurfaces)); + } + return new EveSurfaceComponent( + component.Id, + component.Kind, + component.Props, + children, + component.StateBindingRecords, + component.EmbeddedDocuments, + component.Layout, + component.Style); } private void EnsureLiveSubscriptions() @@ -828,6 +899,25 @@ namespace GameCult.Eve.UnityScene schemaIds: new[] { EveSurfaceDocument.SchemaId }, includeSnapshot: false) .GetAwaiter().GetResult(); + var embeddedSlots = EnumerateEmbeddedDocuments(CurrentSurfaceDocument.SurfaceDocument.Surface.Root) + .Where(slot => string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal)) + .Where(slot => !string.IsNullOrWhiteSpace(slot.DocumentId)) + .GroupBy(slot => slot.DocumentId, StringComparer.Ordinal) + .Select(group => group.First()) + .ToArray(); + for (var index = 0; index < embeddedSlots.Length; index++) + { + var slot = embeddedSlots[index]; + var initialEmbedded = _subscriptions.SubscribeAsync( + $"eve-unity-embedded-surface-{index}", + recordKeys: new[] { slot.DocumentId }, + schemaIds: new[] { EveSurfaceDocument.SchemaId }) + .GetAwaiter().GetResult() + .OfType() + .FirstOrDefault(); + if (initialEmbedded != null) + PublishEmbeddedSurface(slot.DocumentId, initialEmbedded); + } var inputCapabilityRef = FindComponentProp( CurrentSurfaceDocument.SurfaceDocument.Surface.Root, "inputCapability"); @@ -880,11 +970,34 @@ namespace GameCult.Eve.UnityScene QueueEntityView(entityView); else if (change.Document is CultMeshBodyPublicationDocument publication) QueueBodyPublication(publication); - else if (change.Document is EveSurfaceDocument or EveInputCapabilityDocument or + else if (change.Document is EveSurfaceDocument surface) + _liveDocuments.Enqueue(new PendingSurfaceDocument(change.RecordKey, surface)); + else if (change.Document is EveInputCapabilityDocument or EveAssetCatalogDocument or EveCommandReceiptDocument or EveFieldsSplatsDocument) _liveDocuments.Enqueue(change.Document); } + private static IEnumerable EnumerateEmbeddedDocuments(EveSurfaceComponent component) + { + foreach (var slot in component.EmbeddedDocuments ?? Array.Empty()) + yield return slot; + foreach (var child in component.Children ?? Array.Empty()) + foreach (var slot in EnumerateEmbeddedDocuments(child)) + yield return slot; + } + + private sealed class PendingSurfaceDocument + { + public PendingSurfaceDocument(string recordKey, EveSurfaceDocument document) + { + RecordKey = recordKey ?? ""; + Document = document ?? throw new ArgumentNullException(nameof(document)); + } + + public string RecordKey { get; } + public EveSurfaceDocument Document { get; } + } + private static void TraceHotState(string message) { if (string.Equals(Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", StringComparison.Ordinal)) diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs new file mode 100644 index 0000000..c3d1924 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Eve.Surface; +using NUnit.Framework; + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityEmbeddedSurfaceCompositionTests + { + [Test] + public void ComposesAdvertisedReactiveSurfaceWithoutReplacingBaseTopology() + { + const string record = "eve:surface:test.reactive"; + var slot = new EveEmbeddedDocumentSlot( + "reactive", + record, + EveSurfaceDocument.SchemaId, + "reactive-state"); + var world = Component("world", "world.scene3d"); + var mount = new EveSurfaceComponent( + "reactive-mount", + "layer.reactive", + EmptyProps(), + Array.Empty(), + Array.Empty(), + new[] { slot }); + var @base = Surface(4, Component("root", "surface", world, mount)); + var fragment = Surface(12, Component( + "fragment", + "layer.reactive", + Component("shot", "shot.receipt"))); + + var composed = EveUnityCultMeshLiveProviderTransport.ComposeSurface( + @base, + new Dictionary(StringComparer.Ordinal) + { + [record] = fragment + }); + + Assert.That(composed.Version, Is.EqualTo(12)); + Assert.That(composed.Surface.Root.Children[0].Id, Is.EqualTo("world")); + Assert.That(composed.Surface.Root.Children[1].Children.Single().Id, Is.EqualTo("fragment")); + Assert.That(composed.Surface.Root.Children[1].Children.Single().Children.Single().Kind, + Is.EqualTo("shot.receipt")); + } + + private static EveSurfaceDocument Surface(long version, EveSurfaceComponent root) => + new EveSurfaceDocument( + "provider", + "test", + "Test", + version, + "2026-07-19T00:00:00Z", + new EveSurfaceTree("test", root, Array.Empty()), + Array.Empty()); + + private static EveSurfaceComponent Component( + string id, + string kind, + params EveSurfaceComponent[] children) => + new EveSurfaceComponent(id, kind, EmptyProps(), children); + + private static IReadOnlyDictionary EmptyProps() => + new Dictionary(StringComparer.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs.meta new file mode 100644 index 0000000..d304bb8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e721e8052744db6ba925b8a3831de70 diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 8273ea4..747d301 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.81", + "version": "0.3.82", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {