diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs index 6aef631d34e..3b80564ff49 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs @@ -1,4 +1,6 @@ -using System; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.Rooms.Connective; +using System; using System.Threading; namespace CrdtEcsBridge.JsModulesImplementation.Communications @@ -31,6 +33,19 @@ public enum ConnectivityAssertiveness void RemoveSceneMessageHandler(string sceneId, MsgType msgType, SceneMessageHandler onSceneMessage); + /// + /// Routes the given scene's comms to a dedicated room (its own message pipe) instead of the host's + /// current scene room. Idempotent. The caller owns the room and pipe lifecycle; + /// only stops routing and removes this pipe's inbound subscription. + /// + void RegisterSceneRoom(string sceneId, IConnectiveRoom room, IMessagePipe roomPipe); + + /// + /// Stops routing for a previously registered scene room and unsubscribes from its inbound pipe. Disposing + /// the underlying room/pipe is the caller's responsibility. No-op if the scene was never registered. + /// + void RemoveSceneRoom(string sceneId); + void SendMessage(ReadOnlySpan message, string sceneId, ConnectivityAssertiveness assertiveness, CancellationToken ct, string? specialRecipient = null); readonly ref struct DecodedMessage diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs index 5f6647457bf..d6c2e7937fb 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs @@ -1,7 +1,8 @@ -using DCL.Multiplayer.Connections.GateKeeper.Rooms; +using DCL.Multiplayer.Connections.GateKeeper.Rooms; using DCL.Multiplayer.Connections.Messaging; using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.Rooms.Connective; using Decentraland.Kernel.Comms.Rfc4; using Google.Protobuf; using DCL.LiveKit.Public; @@ -24,6 +25,10 @@ public class SceneCommunicationPipe : ISceneCommunicationPipe private readonly IGateKeeperSceneRoom sceneRoom; private readonly IMessagePipe messagePipe; + // Additional per-scene rooms (e.g. authoritative Portable Experience scene rooms) keyed by sceneId. When a + // sceneId has an entry here, its comms are routed to/from that room instead of the host's current scene room. + private readonly Dictionary extraRoomsBySceneId = new (); + public SceneCommunicationPipe(IMessagePipesHub messagePipesHub, IGateKeeperSceneRoom sceneRoom) { this.sceneRoom = sceneRoom; @@ -31,6 +36,40 @@ public SceneCommunicationPipe(IMessagePipesHub messagePipesHub, IGateKeeperScene messagePipe.Subscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber, IMessagePipe.ThreadStrict.ORIGIN_THREAD); } + /// + /// Routes the given scene's comms to a dedicated room (its own message pipe) instead of the host's current + /// scene room. Idempotent. The caller owns the room and pipe lifecycle; only + /// stops routing and unsubscribes from the pipe. + /// + public void RegisterSceneRoom(string sceneId, IConnectiveRoom room, IMessagePipe roomPipe) + { + lock (extraRoomsBySceneId) + { + if (extraRoomsBySceneId.ContainsKey(sceneId)) return; + + // Subscribe before publishing the entry so the inbound subscription is active the moment SendMessage + // (called from the scene thread) can find the entry and route outbound to this pipe. The pipe is + // freshly created for and exclusively owned by this scene, so the single subscription MessagePipe + // allows per message type always succeeds here. + roomPipe.Subscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber, IMessagePipe.ThreadStrict.ORIGIN_THREAD); + extraRoomsBySceneId[sceneId] = new RoomChannel(room, roomPipe); + } + } + + /// + /// Stops routing for a previously registered scene room and unsubscribes + /// from its pipe. Disposing the underlying room/pipe is the caller's responsibility. No-op if not registered. + /// + public void RemoveSceneRoom(string sceneId) + { + lock (extraRoomsBySceneId) + { + if (!extraRoomsBySceneId.Remove(sceneId, out RoomChannel channel)) return; + + channel.Pipe.Unsubscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber); + } + } + private void InvokeSubscriber(ReceivedMessage message) { using (message) @@ -44,7 +83,7 @@ private void InvokeSubscriber(ReceivedMessage message) // TODO: If the room is connected but the scene is not connected **yet** the message will be skipped and forgotten - if (!sceneRoom.IsSceneConnected(message.Payload.SceneId)) return; + if (!IsSceneConnected(message.Payload.SceneId)) return; SubscriberKey key = new (message.Payload.SceneId, msgType); @@ -108,9 +147,23 @@ public void RemoveSceneMessageHandler(string sceneId, ISceneCommunicationPipe.Ms public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommunicationPipe.ConnectivityAssertiveness assertiveness, CancellationToken ct, string? specialRecipient = null) { - if (!sceneRoom.IsSceneConnected(sceneId)) return; + IMessagePipe pipe; + + lock (extraRoomsBySceneId) + { + if (extraRoomsBySceneId.TryGetValue(sceneId, out RoomChannel channel)) + { + if (!IsRoomConnected(channel.Room)) return; + pipe = channel.Pipe; + } + else + { + if (!sceneRoom.IsSceneConnected(sceneId)) return; + pipe = messagePipe; + } + } - MessageWrap sceneMessage = messagePipe.NewMessage(); + MessageWrap sceneMessage = pipe.NewMessage(); if (!string.IsNullOrEmpty(specialRecipient)) sceneMessage.AddSpecialRecipient(specialRecipient); @@ -120,6 +173,19 @@ public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommun sceneMessage.SendAndDisposeAsync(ct, LKDataPacketKind.KindReliable).Forget(); } + private bool IsSceneConnected(string sceneId) + { + lock (extraRoomsBySceneId) + if (extraRoomsBySceneId.TryGetValue(sceneId, out RoomChannel channel)) + return IsRoomConnected(channel.Room); + + return sceneRoom.IsSceneConnected(sceneId); + } + + private static bool IsRoomConnected(IConnectiveRoom room) => + room.CurrentState() == IConnectiveRoom.State.Running + && room.Room().Info.ConnectionState == LKConnectionState.ConnConnected; + private readonly struct SubscriberKey : IEquatable { public readonly string SceneId; @@ -140,5 +206,17 @@ public override bool Equals(object? obj) => public override int GetHashCode() => HashCode.Combine(SceneId, (int)MsgType); } + + private readonly struct RoomChannel + { + public readonly IConnectiveRoom Room; + public readonly IMessagePipe Pipe; + + public RoomChannel(IConnectiveRoom room, IMessagePipe pipe) + { + Room = room; + Pipe = pipe; + } + } } } diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs index 74441bbc29c..5122b736786 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs @@ -364,6 +364,10 @@ public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommun { sendMessageCalls.Add(message.ToArray()); } + + public void RegisterSceneRoom(string sceneId, DCL.Multiplayer.Connections.Rooms.Connective.IConnectiveRoom room, DCL.Multiplayer.Connections.Messaging.Pipe.IMessagePipe roomPipe) { } + + public void RemoveSceneRoom(string sceneId) { } } } } diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs index 58fa3365957..6e966ebaf81 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs @@ -256,7 +256,7 @@ Entity playerEntity dynamicWorldContainer.ProfileRepository, dynamicWorldContainer.RoomHub, dynamicWorldContainer.MvcManager, - dynamicWorldContainer.MessagePipesHub, + dynamicWorldContainer.SceneCommunicationPipe, dynamicWorldContainer.RemoteMetadata, webJsSources, bootstrapContainer.Environment, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs index ecabc6149d5..582ccf7420f 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs @@ -1,5 +1,6 @@ using Arch.Core; using CommunicationData.URLHelpers; +using CrdtEcsBridge.JsModulesImplementation.Communications; using DCL.AssetsProvision; using DCL.DebugUtilities; using DCL.LiveKit.Public; @@ -68,6 +69,8 @@ public class CommsContainer : IDisposable public CurrentSceneInfo CurrentSceneInfo { get; } + public SceneCommunicationPipe SceneCommunicationPipe { get; } + private CommsContainer( IArchipelagoIslandRoom archipelagoIslandRoom, IGateKeeperSceneRoom gateKeeperSceneRoom, @@ -83,7 +86,8 @@ private CommsContainer( RemoteEntities remoteEntities, IRemoteMetadata remoteMetadata, IHealthCheck livekitHealthCheck, - CurrentSceneInfo currentSceneInfo) + CurrentSceneInfo currentSceneInfo, + SceneCommunicationPipe sceneCommunicationPipe) { this.archipelagoIslandRoom = archipelagoIslandRoom; this.gateKeeperSceneRoom = gateKeeperSceneRoom; @@ -100,6 +104,7 @@ private CommsContainer( RemoteMetadata = remoteMetadata; LivekitHealthCheck = livekitHealthCheck; CurrentSceneInfo = currentSceneInfo; + SceneCommunicationPipe = sceneCommunicationPipe; } public static CommsContainer Create( @@ -201,6 +206,8 @@ public static CommsContainer Create( ? livekitHealthCheck.WithFailAnalytics(bootstrapContainer.Analytics.Controller) : livekitHealthCheck; + var sceneCommunicationPipe = new SceneCommunicationPipe(messagePipesHub, roomHub.SceneRoom()); + return new CommsContainer( archipelagoIslandRoom, gateKeeperSceneRoom, @@ -216,7 +223,8 @@ public static CommsContainer Create( remoteEntities, remoteMetadata, livekitHealthCheck, - new CurrentSceneInfo()); + new CurrentSceneInfo(), + sceneCommunicationPipe); } public MultiplayerPlugin CreateMultiplayerPlugin( diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs index 369c19a79d1..8b2f8bcb725 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs @@ -48,6 +48,7 @@ using DCL.Optimization.AdaptivePerformance.Systems; using DCL.PerformanceAndDiagnostics.Analytics; using DCL.PerformanceAndDiagnostics.Analytics.DecoratorBased; +using CrdtEcsBridge.JsModulesImplementation.Communications; using DCL.PluginSystem; using DCL.PluginSystem.Global; using DCL.PluginSystem.SmartWearables; @@ -130,6 +131,8 @@ public class DynamicWorldContainer : DCLWorldContainer public IRoomHub RoomHub => commsContainer.RoomHub; + public SceneCommunicationPipe SceneCommunicationPipe => commsContainer.SceneCommunicationPipe; + public ISystemClipboard SystemClipboard => uiShellContainer.Clipboard; private DynamicWorldContainer( @@ -442,7 +445,7 @@ await MapRendererContainer { new AvatarAttachPlugin(globalWorld, staticContainer.MainPlayerAvatarBaseProxy, staticContainer.ComponentsContainer.ComponentPoolsRegistry, commsContainer.EntityParticipantTable, staticContainer.CharacterContainer.Transform), new SceneMaskedEmotePlugin(globalWorld, playerEntity, staticContainer.MainPlayerAvatarBaseProxy, staticContainer.EmotesContainer.EmotePlayer, staticContainer.EmoteStorage, multiplayerEmotesMessageBus), - new RealmInfoPlugin(staticContainer.RealmData, commsContainer.RoomHub), + new RealmInfoPlugin(staticContainer.RealmData, commsContainer.RoomHub, staticContainer.ScenesCache), }; var characterPreviewEventBus = new CharacterPreviewEventBus(); diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs index eaba16549d8..67da6fa9b1d 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs @@ -129,7 +129,7 @@ public ECSPortableExperiencesController( ISceneFacade parentScene = scenesCache.Scenes.FirstOrDefault(s => s.SceneStateProvider.IsCurrent); string parentSceneName = parentScene != null ? parentScene.Info.Name : "main"; Entity portableExperienceEntity = world.Create(); - world.Add(portableExperienceEntity, new PortableExperienceRealmComponent(realmData, parentSceneName, isGlobalPortableExperience), new PortableExperienceComponent(ens)); + world.Add(portableExperienceEntity, new PortableExperienceRealmComponent(realmData, parentSceneName, isGlobalPortableExperience), new PortableExperienceComponent(ens, realmData)); world.Add(portableExperienceEntity, new PortableExperienceMetadata { Type = isGlobalPortableExperience ? PortableExperienceType.GLOBAL : PortableExperienceType.LOCAL, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs index 80d0e03a48d..5a7fca4d4ae 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs @@ -3,7 +3,6 @@ using CrdtEcsBridge.PoolsProviders; using DCL.PluginSystem.World; using DCL.Multiplayer.Connections.DecentralandUrls; -using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Profiles.Poses; using DCL.PluginSystem.World.Dependencies; @@ -44,7 +43,7 @@ public static SceneSharedContainer Create(in StaticContainer staticContainer, IProfileRepository profileRepository, IRoomHub roomHub, IMVCManager mvcManager, - IMessagePipesHub messagePipesHub, + ISceneCommunicationPipe sceneCommunicationPipe, IRemoteMetadata remoteMetadata, IWebJsSources webJsSources, DecentralandEnvironment dclEnvironment, @@ -83,7 +82,7 @@ public static SceneSharedContainer Create(in StaticContainer staticContainer, realmData, staticContainer.PortableExperiencesController, staticContainer.StaticSettings.SkyboxSettings, - new SceneCommunicationPipe(messagePipesHub, roomHub.SceneRoom()), + sceneCommunicationPipe, remoteMetadata, dclEnvironment, systemClipboard, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs b/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs index 9a32fbb59fe..4310653d252 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs @@ -8,6 +8,7 @@ using DCL.Diagnostics; using DCL.FeatureFlags; using DCL.Multiplayer.Connections.DecentralandUrls; +using CrdtEcsBridge.JsModulesImplementation.Communications; using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Profiles.Poses; @@ -138,7 +139,7 @@ public static class IntegrationTestsSuite new CancellationTokenSource(), Substitute.For() ), - new IMessagePipesHub.Fake(), + Substitute.For(), Substitute.For(), webJsSources, DecentralandEnvironment.Org, diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs index ba3cd3eb237..800713c753b 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs @@ -112,9 +112,17 @@ private void AddNewSceneDefinitionToList(in Entity entity, in PartitionComponent { if (sceneDefinitionComponent.IsPortableExperience) { + // Authoritative-multiplayer PX scenes carry their world's realm so the load flow can join the + // scene-comms room (which spawns the authoritative server). The realm rides on the entity's + // PortableExperienceComponent; null for ordinary PX leaves the load flow untouched. + IRealmData? portableExperienceRealm = sceneDefinitionComponent.Definition.metadata.authoritativeMultiplayer + && World.TryGet(entity, out PortableExperienceComponent portableExperience) + ? portableExperience.RealmData + : null; + //Portable experiences shouldnt be analyzed. Create straight away World.Add(entity, AssetPromise.Create(World, - new GetSceneFacadeIntention(sceneDefinitionComponent, issDescriptor), partitionComponent), SceneLoadingState.CreatePortableExperience()); + new GetSceneFacadeIntention(sceneDefinitionComponent, issDescriptor, portableExperienceRealm), partitionComponent), SceneLoadingState.CreatePortableExperience()); } else { diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs index 5743642c4f3..f6421c3d5ef 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs @@ -4,15 +4,19 @@ namespace ECS.SceneLifeCycle.SceneDefinition { /// /// A component used to indicate which scenes are PX and filter them from certain queries. - /// Also contains an ID to help filter which entities belong to certain PX + /// Also contains an ID to help filter which entities belong to certain PX. + /// Carries the PX world's (name, comms secret) so the scene loading flow + /// can establish the authoritative scene-comms room for the experience. /// public struct PortableExperienceComponent { public readonly ENS Ens; + public readonly IRealmData RealmData; - public PortableExperienceComponent(ENS ens) + public PortableExperienceComponent(ENS ens, IRealmData realmData) { Ens = ens; + RealmData = realmData; } }; } diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs index 1883289f1b1..5a647b8321f 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs @@ -28,10 +28,17 @@ public struct GetSceneFacadeIntention : ILoadingIntention, IEquatable public readonly ISSDescriptor ISSDescriptor; - public GetSceneFacadeIntention(SceneDefinitionComponent definitionComponent, ISSDescriptor issDescriptor) + /// + /// Set only for authoritative-multiplayer Portable Experiences: the PX world's realm data, used by the + /// scene loading flow to establish the scene-comms room. Null for ordinary scenes and non-authoritative PX. + /// + public readonly IRealmData? PortableExperienceRealm; + + public GetSceneFacadeIntention(SceneDefinitionComponent definitionComponent, ISSDescriptor issDescriptor, IRealmData? portableExperienceRealm = null) { DefinitionComponent = definitionComponent; ISSDescriptor = issDescriptor; + PortableExperienceRealm = portableExperienceRealm; // URL = EntityId just for identification, it is used by LoadSystemBase, it won't be used as a URL CommonArguments = new CommonLoadingArguments(definitionComponent.IpfsPath.EntityId); diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs index 26a671c0945..2b7ea780dd7 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs @@ -57,6 +57,19 @@ public async UniTask FlowAsync(World world, ISceneFactory sceneFac await UniTask.SwitchToMainThread(); + // Authoritative-multiplayer Portable Experiences must join their world scene room before the scene is + // considered loaded: the join is what makes the world-content-server spawn the authoritative server. + // The room is owned and torn down by the facade; a failed connect fails the load. + if (intention.PortableExperienceRealm != null && sceneFacade is PortableExperienceSceneFacade portableExperienceScene) + { + try { await portableExperienceScene.StartAuthoritativeRoomAsync(intention.PortableExperienceRealm, ct); } + catch + { + await sceneFacade.DisposeAsync(); + throw; + } + } + sceneFacade.Initialize(); ReportHub.LogProductionInfo($"Loading scene {(sceneFacade.SceneData.IsPortableExperience() ? "(PX)" : "")} '{definition.GetLogSceneName()}' (sdk version: '{sceneData.GetSDKVersion()}') ended"); return sceneFacade; diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs index 8e2f0e9e702..abe99afab70 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs @@ -1,14 +1,90 @@ -using SceneRunner.Scene; +using CrdtEcsBridge.JsModulesImplementation.Communications; +using Cysharp.Threading.Tasks; +using DCL.Diagnostics; +using DCL.LiveKit.Public; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.PortableExperiences; +using DCL.Multiplayer.Connections.Rooms.Connective; +using ECS; +using SceneRunner.Scene; +using System; +using System.Threading; namespace SceneRunner { public class PortableExperienceSceneFacade : SceneFacade { + private readonly PortableExperienceRoomFactory roomFactory; + private readonly ISceneCommunicationPipe sceneCommunicationPipe; + + private IConnectiveRoom? authoritativeRoom; + private IMessagePipe? authoritativeRoomPipe; + private string authoritativeSceneId = string.Empty; + + /// + /// True when this experience's authoritative scene room exists and its LiveKit connection is established. + /// Drives RealmInfo.isConnectedSceneRoom so the SDK initiates the CRDT handshake for the PX. + /// + public bool IsConnectedSceneRoom => + authoritativeRoom != null + && authoritativeRoom.CurrentState() == IConnectiveRoom.State.Running + && authoritativeRoom.Room().Info.ConnectionState == LKConnectionState.ConnConnected; + public PortableExperienceSceneFacade( ISceneData sceneData, - SceneInstanceDependencies.WithRuntimeAndJsAPIBase deps) : base(sceneData, deps) + SceneInstanceDependencies.WithRuntimeAndJsAPIBase deps, + PortableExperienceRoomFactory roomFactory, + ISceneCommunicationPipe sceneCommunicationPipe) : base(sceneData, deps) { + this.roomFactory = roomFactory; + this.sceneCommunicationPipe = sceneCommunicationPipe; SetIsCurrent(true); } + + /// + /// Connects this PX to its world scene room (worlds/{realm}/scenes/{sceneId}/comms) and routes the + /// scene's CRDT through it. Awaited as part of the scene loading flow: joining the room is what makes the + /// world-content-server spawn the authoritative server, so the PX is not considered loaded until connected. + /// + public async UniTask StartAuthoritativeRoomAsync(IRealmData portableExperienceRealm, CancellationToken ct) + { + string? id = SceneData.SceneEntityDefinition.id; + if (string.IsNullOrEmpty(id)) return; + + authoritativeSceneId = id; + (authoritativeRoom, authoritativeRoomPipe) = roomFactory.Create(portableExperienceRealm, authoritativeSceneId); + sceneCommunicationPipe.RegisterSceneRoom(authoritativeSceneId, authoritativeRoom, authoritativeRoomPipe); + + ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: connecting scene '{authoritativeSceneId}' of world '{portableExperienceRealm.RealmName}'"); + + // The connection is a precondition of loading the PX: joining the room is what makes the server spawn, + // so a failed join must fail the load (the caller disposes this facade, tearing the room down). + bool connected = await authoritativeRoom.StartAsync().AttachExternalCancellation(ct); + + if (!connected) + throw new Exception($"Could not connect the authoritative scene room for Portable Experience '{authoritativeSceneId}'"); + } + + protected override void DisposeInternal() + { + if (authoritativeRoom != null) + { + sceneCommunicationPipe.RemoveSceneRoom(authoritativeSceneId); + authoritativeRoomPipe?.Dispose(); + StopAndDisposeAsync(authoritativeRoom).Forget(); + authoritativeRoom = null; + authoritativeRoomPipe = null; + } + + base.DisposeInternal(); + } + + private static async UniTaskVoid StopAndDisposeAsync(IConnectiveRoom room) + { + try { await room.StopIfNotAsync(); } + catch (OperationCanceledException) { } + catch (Exception e) { ReportHub.LogException(e, new ReportData(ReportCategory.COMMS_SCENE_HANDLER)); } + finally { room.Dispose(); } + } } } diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs index 9163f64eb33..a3961fd2404 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs @@ -118,7 +118,7 @@ public async UniTask DisposeAsync() SceneStateProvider.State.Set(SceneState.Disposed); } - private void DisposeInternal() => + protected virtual void DisposeInternal() => deps.Dispose(); public void SetTargetFPS(int fps) diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs index 57b80df8ba6..67b9bf250c1 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs @@ -12,6 +12,7 @@ using DCL.Interaction.Utility; using DCL.Ipfs; using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Profiles.Poses; using DCL.Profiles; @@ -69,6 +70,7 @@ public class SceneFactory : ISceneFactory private readonly DecentralandEnvironment dclEnvironment; private readonly ISystemClipboard systemClipboard; private readonly string installSource; + private readonly PortableExperienceRoomFactory portableExperienceRoomFactory; private IGlobalWorldActions globalWorldActions = null!; @@ -119,6 +121,8 @@ public SceneFactory( this.remoteMetadata = remoteMetadata; this.dclEnvironment = dclEnvironment; this.installSource = installSource; + + portableExperienceRoomFactory = new PortableExperienceRoomFactory(webRequestController, identityCache, decentralandUrlsSource); } public async UniTask CreateSceneFromFileAsync(string jsCodeUrl, IPartitionComponent partitionProvider, CancellationToken ct, string id = "") @@ -277,7 +281,9 @@ private async UniTask CreateSceneAsync(ISceneData sceneData, IJsAp { return new PortableExperienceSceneFacade( sceneData, - runtimeDeps + runtimeDeps, + portableExperienceRoomFactory, + messagePipesHub ); } diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs b/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs index 80e87a344cf..4f1ab447154 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs @@ -279,6 +279,10 @@ public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommun { sendMessageCalls.Add(message.ToArray()); } + + public void RegisterSceneRoom(string sceneId, DCL.Multiplayer.Connections.Rooms.Connective.IConnectiveRoom room, DCL.Multiplayer.Connections.Messaging.Pipe.IMessagePipe roomPipe) { } + + public void RemoveSceneRoom(string sceneId) { } } } } diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs index 2d0bea26b07..bd6e9409584 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs @@ -23,6 +23,12 @@ enum ThreadStrict void Subscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived, ThreadStrict threadStrict = ThreadStrict.MAIN_THREAD_ONLY) where T: class, IMessage, new(); + /// + /// Removes the subscription for the given message type. Only one subscriber per type is allowed + /// (see ), so the type uniquely identifies the subscription to remove. + /// + void Unsubscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived) where T: class, IMessage, new(); + class Null : IMessagePipe { private Null() {} @@ -33,6 +39,7 @@ private Null() {} throw new Exception("Null implementation"); public void Subscribe(Packet.MessageOneofCase _, Action> __, ThreadStrict ___) where T: class, IMessage, new() { } + public void Unsubscribe(Packet.MessageOneofCase _, Action> __) where T: class, IMessage, new() { } public void Dispose() { } } } diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs index 10b9f58bbf4..55e7c26d9f9 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs @@ -42,6 +42,14 @@ public LogMessagePipe(IMessagePipe origin, string fromPipe) ); } + public void Unsubscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived) where T: class, IMessage, new() + { + ReportHub.Log( + ReportCategory.LIVEKIT, $"From: {fromPipe} LogMessagePipe: Unsubscribing from messages of type {typeof(T).FullName}"); + + origin.Unsubscribe(ofCase, onMessageReceived); + } + public void Dispose() { origin.Dispose(); diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs index 17121370444..ea7a897a521 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs @@ -174,6 +174,14 @@ private async UniTaskVoid NotifySubscribersAsync(Packet.MessageOneofCase name, P ); } + public void Unsubscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived) where T: class, IMessage, new() + { + // Only one subscriber per type is allowed (see Subscribe), so dropping the type's entry removes + // that single subscription. An in-flight NotifySubscribersAsync holds its own list reference, so + // removing the dictionary key here does not disrupt a notification already in progress. + subscribers.Remove(ofCase); + } + private (List> list, IMessagePipe.ThreadStrict strict) SubscribersList(Packet.MessageOneofCase typeName, IMessagePipe.ThreadStrict threadStrict) { if (subscribers.TryGetValue(typeName, out (List> list, IMessagePipe.ThreadStrict strict) item) == false) diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences.meta new file mode 100644 index 00000000000..ec724a5826b --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ab862128a6be94812b385e5fb9b46d69 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs new file mode 100644 index 00000000000..ab98cd82509 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs @@ -0,0 +1,46 @@ +using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.Pools; +using DCL.Multiplayer.Connections.Rooms; +using DCL.Multiplayer.Connections.Rooms.Connective; +using DCL.Web3.Identities; +using DCL.WebRequests; +using ECS; +using LiveKit.Internal.FFIClients.Pools; +using LiveKit.Internal.FFIClients.Pools.Memory; +using System.Buffers; + +namespace DCL.Multiplayer.Connections.PortableExperiences +{ + /// + /// Stateless factory that builds a scene-level LiveKit room () plus a + /// over its data channel for an authoritative Portable Experience scene. The returned + /// room is not started; the caller owns its lifecycle (start it during the scene load, dispose it on unload). + /// + public class PortableExperienceRoomFactory + { + private readonly IWebRequestController webRequests; + private readonly IWeb3IdentityCache identityCache; + private readonly IDecentralandUrlsSource urlsSource; + private readonly IMultiPool multiPool; + private readonly IMemoryPool memoryPool; + + public PortableExperienceRoomFactory(IWebRequestController webRequests, IWeb3IdentityCache identityCache, IDecentralandUrlsSource urlsSource) + { + this.webRequests = webRequests; + this.identityCache = identityCache; + this.urlsSource = urlsSource; + multiPool = new DCLMultiPool(); + memoryPool = new ArrayMemoryPool(ArrayPool.Shared!); + } + + public (IConnectiveRoom room, IMessagePipe pipe) Create(IRealmData portableExperienceRealm, string sceneId) + { + var room = new PortableExperienceSceneRoom(webRequests, identityCache, urlsSource, portableExperienceRealm, sceneId); + IMessagePipe pipe = new MessagePipe(room.Room().DataPipe, multiPool, memoryPool, RoomSource.GATEKEEPER) + .WithLog("PortableExperienceScene"); + + return (room, pipe); + } + } +} diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs.meta new file mode 100644 index 00000000000..6b814822d1c --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: efb5838e4fcf243ad96c2bf483571349 \ No newline at end of file diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs new file mode 100644 index 00000000000..e6d7f0dbb29 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs @@ -0,0 +1,79 @@ +using Cysharp.Threading.Tasks; +using DCL.Diagnostics; +using DCL.LiveKit.Public; +using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Multiplayer.Connections.GateKeeper.Meta; +using DCL.Multiplayer.Connections.Rooms.Connective; +using DCL.Web3.Identities; +using DCL.WebRequests; +using ECS; +using System; +using System.Threading; +using UnityEngine; + +namespace DCL.Multiplayer.Connections.PortableExperiences +{ + /// + /// A scene-level LiveKit comms connection to a Portable Experience world's scene room + /// (worlds/{realm}/scenes/{sceneId}/comms). The join carries the sceneId, which is what the + /// world-content-server's room-event-processor needs to spawn the authoritative server for the experience. + /// Unlike GateKeeperSceneRoom it always targets the world scene endpoint: a PX realm is configured with an + /// empty WorldManifest (so RealmData.SingleScene == true), which would otherwise route comms to the + /// world-level room that carries no sceneId. + /// + public class PortableExperienceSceneRoom : ConnectiveRoom + { + private readonly IWebRequestController webRequests; + private readonly IWeb3IdentityCache identityCache; + private readonly IDecentralandUrlsSource urlsSource; + private readonly IRealmData realmData; + private readonly string sceneId; + + public PortableExperienceSceneRoom(IWebRequestController webRequests, IWeb3IdentityCache identityCache, IDecentralandUrlsSource urlsSource, IRealmData realmData, string sceneId) + { + this.webRequests = webRequests; + this.identityCache = identityCache; + this.urlsSource = urlsSource; + this.realmData = realmData; + this.sceneId = sceneId; + } + + protected override UniTask PrewarmAsync(CancellationToken token) => + UniTask.CompletedTask; + + protected override async UniTask CycleStepAsync(CancellationToken token) + { + // Skip if identity is not available (e.g., during sign-out). + if (identityCache.Identity == null) + return; + + if (CurrentState() is not IConnectiveRoom.State.Running + || Room().Info.ConnectionState != LKConnectionState.ConnConnected) + { + string connectionString = await ConnectionStringAsync(token); + await TryConnectToRoomAsync(connectionString, token); + } + } + + private async UniTask ConnectionStringAsync(CancellationToken token) + { + string url = string.Format(urlsSource.Url(DecentralandUrl.WorldCommsAdapter), realmData.RealmName, sceneId); + var meta = new MetaData(sceneId, Vector2Int.zero, new MetaData.Input(realmData.RealmName, Vector2Int.zero)); + + ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"[PortableExperienceSceneRoom] Requesting adapter from '{url}' for scene '{sceneId}'"); + + AdapterResponse response = await webRequests + .SignedFetchPostAsync(url, meta.BuildWithSecret(realmData.WorldCommsSecret), token) + .CreateFromJson(WRJsonParser.Unity); + + return string.IsNullOrEmpty(response.adapter) ? response.fixedAdapter : response.adapter; + } + + [Serializable] + private struct AdapterResponse + { + public string adapter; + public string fixedAdapter; + } + } +} diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs.meta new file mode 100644 index 00000000000..2ddfee91a6c --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f07be5e8926614100957da02ab4056c8 \ No newline at end of file diff --git a/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs index 804e378cd94..f7afc19c07c 100644 --- a/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs @@ -4,6 +4,7 @@ using DCL.SDKComponents.RealmInfo; using ECS; using ECS.LifeCycle; +using ECS.SceneLifeCycle; using System.Collections.Generic; namespace DCL.PluginSystem.World @@ -12,11 +13,13 @@ public class RealmInfoPlugin : IDCLWorldPluginWithoutSettings { private readonly IRealmData realmData; private readonly IRoomHub roomHub; + private readonly IScenesCache scenesCache; - public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub) + public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub, IScenesCache scenesCache) { this.realmData = realmData; this.roomHub = roomHub; + this.scenesCache = scenesCache; } public void Dispose() @@ -26,7 +29,7 @@ public void Dispose() public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in ECSWorldInstanceSharedDependencies sharedDependencies, in SystemsDependencies systemsDependencies, in PersistentEntities persistentEntities, List finalizeWorldSystems, List sceneIsCurrentListeners) { - WriteRealmInfoSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter, realmData, roomHub, sharedDependencies.SceneData); + WriteRealmInfoSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter, realmData, roomHub, sharedDependencies.SceneData, scenesCache); } } } diff --git a/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs b/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs index 288ffb00920..9f3ab30f991 100644 --- a/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs +++ b/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs @@ -10,6 +10,7 @@ using DCL.Multiplayer.Connections.Rooms.Connective; using DCL.Utilities; using ECS; +using ECS.SceneLifeCycle; using ECS.TestSuite; using LiveKit.Rooms; using LiveKit.Rooms.Info; @@ -26,6 +27,8 @@ public class WriteRealmInfoSystemShould : UnitySystemTestBase(); + sceneData = Substitute.For(); sceneData.SceneEntityDefinition.Returns(new SceneEntityDefinition()); IGateKeeperSceneRoom? gateKeeperSceneRoom = Substitute.For(); @@ -58,7 +61,9 @@ public void Setup() roomHub.SceneRoom().Returns(gateKeeperSceneRoom); - system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData); + scenesCache = Substitute.For(); + + system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData, scenesCache); } protected override void OnTearDown() @@ -149,5 +154,23 @@ public void WriteRealmInfoDataOnInitializeRegardlessOfIsDirtyFlag() AssertPutMessageReceived(); } + + [Test] + public void OmitHostSceneRoomCheckForPortableExperience() + { + // For a PX the connection state is read from the scene's own facade (via the scenes cache), not the + // host's scene room: with no PX facade registered the scene reports as not connected even though the + // host scene room ('gateKeeperSceneRoom') is connected. + sceneData.IsPortableExperience().Returns(true); + sceneData.SceneEntityDefinition.Returns(new SceneEntityDefinition { id = "px-scene" }); + + system.Update(0); + + ecsToCRDTWriter.Received(1) + .PutMessage( + Arg.Any>(), + SpecialEntitiesID.SCENE_ROOT_ENTITY, + Arg.Is<(IRealmData realmData, WriteRealmInfoSystem.CommsRoomInfo roomInfo)>(data => !data.roomInfo.IsConnectedSceneRoom)); + } } } diff --git a/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs b/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs index e76c089b153..ab6968a69e1 100644 --- a/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs +++ b/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs @@ -3,12 +3,12 @@ using CrdtEcsBridge.Components; using CrdtEcsBridge.ECSToCRDTWriter; using DCL.ECSComponents; -using DCL.Multiplayer.Connections.GateKeeper.Rooms; using DCL.Multiplayer.Connections.RoomHubs; -using DCL.Multiplayer.Connections.Rooms.Connective; using ECS; using ECS.Abstract; using ECS.Groups; +using ECS.SceneLifeCycle; +using SceneRunner; using SceneRunner.Scene; namespace DCL.SDKComponents.RealmInfo @@ -22,13 +22,13 @@ public partial class WriteRealmInfoSystem : BaseUnityLoopSystem private bool initialized; - internal WriteRealmInfoSystem(World world, IECSToCRDTWriter ecsToCRDTWriter, IRealmData realmData, IRoomHub roomHub, ISceneData sceneData) + internal WriteRealmInfoSystem(World world, IECSToCRDTWriter ecsToCRDTWriter, IRealmData realmData, IRoomHub roomHub, ISceneData sceneData, IScenesCache scenesCache) : base(world) { this.ecsToCRDTWriter = ecsToCRDTWriter; this.realmData = realmData; - commsRoomInfo = new CommsRoomInfo(roomHub, sceneData); + commsRoomInfo = new CommsRoomInfo(roomHub, sceneData, scenesCache); } public override void Initialize() @@ -71,15 +71,17 @@ public class CommsRoomInfo { private readonly IRoomHub roomHub; private readonly ISceneData sceneData; + private readonly IScenesCache scenesCache; public string IslandSid { get; private set; } public bool IsConnectedSceneRoom { get; private set; } - public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData) + public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData, IScenesCache scenesCache) { this.roomHub = roomHub; this.sceneData = sceneData; + this.scenesCache = scenesCache; } /// @@ -88,8 +90,12 @@ public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData) /// public bool TryFetchNewInfo() { - IGateKeeperSceneRoom sceneRoom = roomHub.SceneRoom(); - bool isConnectedToSceneRoom = sceneRoom.IsSceneConnected(sceneData.SceneEntityDefinition.id); + // A Portable Experience scene connects through its own scene room, owned by its facade — not the + // host's current scene room — so for a PX the connection state is read from the scene's facade and + // the host scene-room check is omitted entirely (it is always false for a PX). + bool isConnectedToSceneRoom = sceneData.IsPortableExperience() + ? IsPortableExperienceSceneRoomConnected() + : roomHub.SceneRoom().IsSceneConnected(sceneData.SceneEntityDefinition.id); string room = roomHub.IslandRoom().Info.Sid ?? string.Empty; @@ -102,6 +108,15 @@ public bool TryFetchNewInfo() return true; } + private bool IsPortableExperienceSceneRoomConnected() + { + string sceneId = sceneData.SceneEntityDefinition.id; + + return !string.IsNullOrEmpty(sceneId) + && scenesCache.TryGetPortableExperienceBySceneUrn(sceneId, out ISceneFacade facade) + && facade is PortableExperienceSceneFacade { IsConnectedSceneRoom: true }; + } + public void WriteToComponent(PBRealmInfo component) { component.Room = IslandSid; diff --git a/docs/README.md b/docs/README.md index d25e0a96fa8..ea8a54b2ba6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,7 @@ Welcome to the official documentation for Unity Explorer — the Decentraland cl - **[Memory & Resource Unloading](memory-budgeting-and-resource-unloading.md)** — Memory budgeting and cache unloading strategies - **[Multiplayer](multiplayer.md)** — Transport-agnostic multiplayer hub: shared interfaces, movement pipeline, entity/profile tables, SDK propagation, dual-transport wiring - **[LiveKit Networking](livekit-networking.md)** — LiveKit transport: dual-room architecture, messaging pipes, Archipelago/GateKeeper, voice and chat rooms +- **[Authoritative Portable Experiences](authoritative-portable-experiences.md)** — Running an authoritative server (Hammurabi) for a Portable Experience: scene-level comms rooms, the spawn trigger, and the CRDT handshake - **[Pulse](pulse.md)** — Pulse transport: ENet peer transport, peer identity, protocol, feature-flag gating - **[Diagnostics](diagnostics.md)** — ReportHub logging system and Sentry integration diff --git a/docs/authoritative-portable-experiences.md b/docs/authoritative-portable-experiences.md new file mode 100644 index 00000000000..0a87d3cd0d9 --- /dev/null +++ b/docs/authoritative-portable-experiences.md @@ -0,0 +1,246 @@ +# Authoritative servers for Portable Experiences + +This page explains how a Portable Experience (PX) loaded with `/loadpx` runs +against an authoritative server (codename Hammurabi), and how the Unity client +was wired to make that work. It covers the end-to-end flow across three +services, the client-side components, the constraints that shaped the design, +the three root causes that had to be solved, and the diagnostics used to find +them. + +If you only need the short version: an authoritative PX scene must join its +**own world's scene-level comms room** so the server spawns and the SDK's CRDT +handshake can run over that room — neither of which happens for a PX by default. + +For the transport-level background this builds on, see +[LiveKit Networking](livekit-networking.md), [Multiplayer](multiplayer.md), and +[Scene Runtime](scene-runtime.md). For how PX scenes load, see +[IPFS Realms](ipfs-realms.md). + +## What "authoritative multiplayer" means here + +A scene whose entity definition metadata has `authoritativeMultiplayer == true` +does not run its multiplayer logic peer-to-peer. Instead: + +- A server-side runtime (Hammurabi) runs the scene and owns its CRDT state. +- The client routes the scene's CRDT to a single hardcoded LiveKit participant, + `authoritative-server` (`LiveKitMessagesBroadcaster.AUTH_SERVER_IDENTITY`), + instead of broadcasting to peers + (`CommunicationsControllerAPIImplementationBase.SendBinary`). +- The server is spawned on demand when a user **joins the scene's LiveKit + room**, then joins that room as `authoritative-server` and exchanges CRDT with + every connected client. + +This already works when you teleport directly into an authoritative world. The +work documented here makes it also work when the same world is loaded as a +Portable Experience overlay. + +## End-to-end flow + +Three services cooperate. The client owns only the first column; the other two +live in separate repositories (`comms-gatekeeper` and `sdk-multiplayer-server`). + +``` +unity-explorer (client) comms-gatekeeper sdk-multiplayer-server +───────────────────────── ───────────────── ────────────────────── +PX scene loads (authoritative) + │ + ├─ connect scene room ──► POST /get-scene-adapter + │ worlds// (signed-fetch, sceneId) + │ scenes//comms │ + │ ├─ room name encodes sceneId: + │ │ {prefix}{world}-{sceneId} + │ └─ returns LiveKit token ──► (LiveKit join webhook) + │ room-event-processor: + │ join carries sceneId + │ → spawn Hammurabi + │ ◄──────────────────── authoritative-server joins the same room ◄──┘ + │ + ├─ RealmInfo.isConnectedSceneRoom = true + │ → SDK emits REQ_CRDT_STATE ──► authoritative-server + │ ◄── RES_CRDT_STATE / CRDT ──────┘ (full state, then deltas) + ▼ + scene synced +``` + +The decisive detail is that the join must reach the **scene-level** room +(`worlds//scenes//comms`), whose LiveKit room name encodes the +`sceneId`. The world-level room (`worlds//comms`) carries no `sceneId`, +so the server's `room-event-processor` logs `Join event missing sceneId` and +never spawns. + +## Client architecture + +The PX room is **owned by the scene's lifecycle**: connected as a blocking step +of the scene load and disposed when the scene's facade is disposed. There is no +global system polling scene state. Client code lives in `DCL.Multiplayer` (the +comms primitives), `SceneRuntime` (the scene comms pipe), and the scene-loading / +facade code in `SceneLifeCycle` and `SceneRunner`. No new assembly was introduced. + +| Component | Assembly | Responsibility | +|---|---|---| +| `PortableExperienceSceneRoom` | `DCL.Multiplayer` | A `ConnectiveRoom` that connects to `worlds//scenes//comms` with the `sceneId` in the handshake metadata. Always targets the scene endpoint. | +| `PortableExperienceRoomFactory` | `DCL.Multiplayer` | Stateless builder of a `PortableExperienceSceneRoom` plus its `MessagePipe` for a given realm and sceneId. Holds the LiveKit pools the message pipe needs. | +| `PortableExperienceSceneFacade` | `SceneRunner` | Owns the room and pipe for its PX scene. Connects during load (`StartAuthoritativeRoomAsync`), exposes `IsConnectedSceneRoom`, and removes routing from the pipe + stops/disposes the room in `DisposeInternal`. | +| `LoadSceneSystemLogicBase` (extended) | `SceneLifeCycle` | After the facade is built, awaits the PX room connect as a load precondition — a failed connect fails the load (block-on-load). | +| `SceneCommunicationPipe` (extended) | `SceneRuntime` | Gained per-scene room routing: `RegisterSceneRoom` / `RemoveSceneRoom`. Outbound, inbound, and `IsSceneConnected` for a registered sceneId route to that scene's PX room instead of the host's current scene room. The host-room path is unchanged. | +| `WriteRealmInfoSystem` (extended) | `Assembly-CSharp` | For a PX, reports `RealmInfo.isConnectedSceneRoom` from the scene's own facade (looked up via `IScenesCache`), omitting the host-room check entirely. | + +### Wiring and ownership + +The shared `SceneCommunicationPipe` is created once in `CommsContainer` (hoisted +out of `SceneSharedContainer` so both the scene factory and the scene-loading +flow share one instance, avoiding an `ObjectProxy`). `SceneFactory` builds a +`PortableExperienceRoomFactory` and hands it — plus the shared pipe — to every +`PortableExperienceSceneFacade` it creates. + +The room is thus owned by the facade cradle-to-grave. It is connected as an +awaited step of `LoadSceneSystemLogicBase.FlowAsync` (joining the room triggers +the server spawn, so the PX is not considered loaded until connected) and torn +down — routing removed from the pipe, room stopped and disposed — when the facade +is disposed by the normal unload path (`UnloadSceneSystem`). No global system +reconciles scene state every frame. + +The PX `RealmData` the room needs reaches the load flow on the scene entity's +`PortableExperienceComponent` (set by `ECSPortableExperiencesController` where the +realm is created), which `ResolveSceneStateByIncreasingRadiusSystem` reads into +`GetSceneFacadeIntention.PortableExperienceRealm` for authoritative PX scenes. +The dependency direction stays `SceneRuntime → DCL.Multiplayer`; the pipe never +references the rooms — the facade registers its room with the pipe instead. + +## Constraints that shaped the design + +These are the non-obvious rules that ruled out simpler approaches. + +- **The comms layer is single-realm.** `RoomHub` holds exactly four fixed rooms + (Island, Scene, Chat, Voice) tied to the player's current realm. A PX is an + overlay that does not change the current realm, so its `RealmData.CommsAdapter` + is captured on `PortableExperienceRealmComponent` but never fed into `RoomHub`. + A PX therefore needs its **own** room, modeled per-scene rather than added to + `RoomHub`. +- **PX scenes are excluded from the by-parcel index.** `ControlSceneUpdateLoopSystem` + stores PX scenes in `portableExperienceScenesByUrn`, not `scenesByParcels`, so + `IScenesCache.TryGetByParcel` never returns a PX. This is why remote players + are never propagated into a PX world and why the host scene room is never + connected to a PX scene. +- **A PX realm reports `SingleScene == true`.** `ECSPortableExperiencesController` + builds the PX `RealmData` with `WorldManifest.Empty`, so `RealmData.SingleScene` + is `true`. `GateKeeperSceneRoomOptions.GetAdapterURL` only uses the world + scene endpoint when `IsWorld() && !SingleScene`, so reusing `GateKeeperSceneRoom` + for a PX would hit the wrong (world-level) endpoint. `PortableExperienceSceneRoom` + bypasses this by always building the scene-level URL. +- **Assembly direction.** `SceneRuntime` may reference `DCL.Multiplayer`, never + the reverse. The pipe (in `SceneRuntime`) never references the rooms (in + `DCL.Multiplayer`); the facade registers its room with the pipe instead. + +## The three problems we solved + +Getting a PX to talk to its authoritative server required fixing three distinct +gaps, each surfaced by a different symptom. + +### 1. No spawn trigger + +By default a PX never joins any scene comms room, so the server side never sees +a user and never spawns. The fix wires the room into the scene's own lifecycle: +when an authoritative PX scene loads, `PortableExperienceSceneFacade` opens a +data-only LiveKit connection (`PortableExperienceSceneRoom`) to that world's +scene comms, awaited as a load precondition. + +### 2. Wrong room — the join carried no sceneId + +The first implementation connected to the **world-level** adapter +(`worlds//comms`). The server received the join but logged +`Join event missing sceneId` and skipped the spawn, because the world-level +LiveKit room name carries no `sceneId`. + +The fix is `PortableExperienceSceneRoom`, which connects to the **scene-level** +endpoint (`worlds//scenes//comms`) with the `sceneId` in the +signed handshake metadata. `comms-gatekeeper` then names the room +`{worldRoomPrefix}{world}-{sceneId}`, and its `getRoomMetadataFromRoomName` +parses the `sceneId` back out, so the server's join event carries it and +Hammurabi spawns. + +### 3. No CRDT handshake — the SDK never sent `REQ_CRDT_STATE` + +With the room connected and the server spawned, no CRDT flowed in either +direction. The Unity comms plumbing was correct (`SendBinary` reached the pipe), +but the SDK runtime handed it an **empty** outgoing batch every tick. + +The cause is in `js-sdk-toolchain`. In +`packages/@dcl/sdk/src/network/message-bus-sync.ts`, the function that emits the +bootstrap `REQ_CRDT_STATE` is gated on the scene's `RealmInfo`: + +```ts +if (!RealmInfo.getOrNull(engine.RootEntity)?.isConnectedSceneRoom) return +binaryMessageBus.emit(CommsMessage.REQ_CRDT_STATE, new Uint8Array()) +// ...and requestState() itself is triggered by: +RealmInfo.onChange(... if (value?.isConnectedSceneRoom && !stateIsSyncronized) requestState()) +``` + +The client writes `RealmInfo` through `WriteRealmInfoSystem`, which computed +`isConnectedSceneRoom` from the **host** scene room (`roomHub.SceneRoom()`) — +never connected to a PX scene, so always `false`. The SDK therefore never +requested state. + +The fix makes `WriteRealmInfoSystem` branch for a PX: instead of the host scene +room (always `false`, a false signal), it reads `IsConnectedSceneRoom` from the +scene's own `PortableExperienceSceneFacade`, looked up via `IScenesCache`. When +the PX room finishes connecting, the flag flips, `RealmInfo.onChange` fires, +`requestState()` runs, and `REQ_CRDT_STATE` finally reaches `authoritative-server`, +which responds with `RES_CRDT_STATE` and ongoing CRDT deltas. + +## Message types and the handshake + +The binary message bus uses a one-byte message type that must stay aligned with +the SDK (`CommunicationsControllerAPIImplementationBase.CommsMessageType`): + +| Value | Type | Direction | +|---|---|---| +| 1 | `CRDT` | Both — incremental state | +| 2 | `REQ_CRDT_STATE` | Client → server — request full state on connect | +| 3 | `RES_CRDT_STATE` | Server → client — full state response | + +`REQ_CRDT_STATE` is sent with `DELIVERY_ASSERTED` and the SDK retries it for a +few seconds; `CRDT` deltas use `DROP_IF_NOT_CONNECTED`. + +## Diagnostics and gotchas + +These cost real time during development; keep them in mind when debugging comms. + +- **Use `ReportHub.LogProductionInfo` for must-see logs.** Plain `Debug.Log` + /`Debug.LogWarning` is swallowed or redirected by the project's custom log + handler, and category logs (for example `COMMS_SCENE_HANDLER`) are filtered by + the severity matrix. `LogProductionInfo` renders as the always-visible + `[ALWAYS]` lines. See [Diagnostics](diagnostics.md) and + [Override Debug Log Matrix](override-debug-log-matrix.md). +- **The Console error count is the source of truth, not `Editor.log`.** + `Editor.log` is append-only across every recompile, so grepping it for + `error CS` mixes results from many transient builds (the Editor auto-compiles + after every saved file). Check the live Console count instead. +- **Script changes do not compile during Play mode.** Unity defers recompilation + until you exit Play. A `⌘R` refresh while playing reports `compile time=0`. +- **`InteriorRoom.DataPipe` is a stable proxy.** A `MessagePipe` built over a + room's data pipe before the room connects keeps receiving across LiveKit room + swaps, because `InteriorDataPipe.Assign` re-points to each new underlying room. + This is why the PX message pipe can be built eagerly. + +### Verify a PX is talking to its authoritative server + +1. Confirm the world is reachable: open + `https://worlds-content-server.decentraland.org/world/.dcl.eth/about` + and check `configurations.scenesUrn` is non-empty. +2. With `explorer-alfa-portable-experiences-chat-commands` enabled, run + `/loadpx ` once you are in-world. +3. In `sdk-multiplayer-server` logs, confirm `Spawning process for + authoritativeMultiplayer scene` with the scene's entity hash. +4. In the Unity Console, confirm the scene room connects, the SDK emits a + `REQ_CRDT_STATE` (`firstByte=2`), and the client receives `RES_CRDT_STATE` + from the server. + +## See also + +- [LiveKit Networking](livekit-networking.md) — rooms, message pipes, GateKeeper +- [Multiplayer](multiplayer.md) — transport-neutral hub and SDK propagation +- [Scene Runtime](scene-runtime.md) — CRDT bridge and the comms controller +- [IPFS Realms](ipfs-realms.md) — realms, worlds, and `RealmData` +- [Architecture Overview](architecture-overview.md) — containers and the + "deferred dependencies" rules that shaped the wiring