From d18bdc5bc6bfbee5beef1951f5398823b84d6d8b Mon Sep 17 00:00:00 2001 From: Mikhail Agapov Date: Wed, 20 May 2026 19:34:54 +0300 Subject: [PATCH 1/5] Maintain LocalPlayer-related data on all scenes throughout their whole lifecycle - Local Player is propagated on Initialize from the scene world so it's available on `onStart` - Local Player data updated in every scene even if it's not current --- .../Global/Dynamic/DynamicWorldContainer.cs | 2 +- .../Infrastructure/Global/StaticContainer.cs | 7 +- .../SceneRunner/Scene/SceneEcsExecutor.cs | 1 - .../Assets/DCL/Multiplayer/AssemblyInfo.cs | 4 + .../DCL/Multiplayer/AssemblyInfo.cs.meta | 2 + .../PlayerCRDTEntitiesHandlerSystem.cs | 52 ++++++++---- .../PlayerProfileDataPropagationSystem.cs | 3 +- .../LocalPlayerCRDTEntityHandlerSystem.cs | 76 +++++++++++++++++ ...LocalPlayerCRDTEntityHandlerSystem.cs.meta | 2 + .../SceneWorld/WriteSDKAvatarBaseSystem.cs | 11 ++- .../PlayerCRDTEntitiesHandlerSystemShould.cs | 39 +++++++-- .../Tests/SeedLocalPlayerCRDTSystemShould.cs | 84 +++++++++++++++++++ .../SeedLocalPlayerCRDTSystemShould.cs.meta | 2 + .../PluginSystem/World/MultiplayerPlugin.cs | 19 ++++- 14 files changed, 275 insertions(+), 29 deletions(-) create mode 100644 Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs create mode 100644 Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs.meta create mode 100644 Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs create mode 100644 Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs.meta create mode 100644 Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs create mode 100644 Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs.meta diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs index be49f5f70d9..865c73cb0b7 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs @@ -552,7 +552,7 @@ await MapRendererContainer dynamicWorldDependencies.WorldInfoTool.Initialize(worldInfoHub); - var characterDataPropagationUtility = new CharacterDataPropagationUtility(staticContainer.ComponentsContainer.ComponentPoolsRegistry.AddComponentPool()); + CharacterDataPropagationUtility characterDataPropagationUtility = staticContainer.CharacterDataPropagationUtility; var currentSceneInfo = new CurrentSceneInfo(); diff --git a/Explorer/Assets/DCL/Infrastructure/Global/StaticContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/StaticContainer.cs index ea93667f171..6165c1ab59e 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/StaticContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/StaticContainer.cs @@ -68,6 +68,7 @@ using ECS.Unity.AssetLoad.Cache; using Global.Dynamic; using Utility; +using DCL.Multiplayer.SDK.Systems.GlobalWorld; using MultiplayerPlugin = DCL.PluginSystem.World.MultiplayerPlugin; namespace Global @@ -134,6 +135,7 @@ public class StaticContainer : IDCLPlugin public IGltfContainerAssetsCache GltfContainerAssetsCache { get; private set; } public AssetPreLoadCache AssetPreLoadCache { get; private set; } + public CharacterDataPropagationUtility CharacterDataPropagationUtility { get; private set; } public void Dispose() { @@ -281,6 +283,9 @@ public UniTask InitializeAsync(StaticSettings settings, CancellationToken ct) var promisesAnalyticsPlugin = new PromisesAnalyticsPlugin(debugContainerBuilder); + container.CharacterDataPropagationUtility = new CharacterDataPropagationUtility( + componentsContainer.ComponentPoolsRegistry.AddComponentPool()); + container.ECSWorldPlugins = new IDCLWorldPlugin[] { new GltfContainerPlugin(sharedDependencies, container.CacheCleaner, container.SceneReadinessReportQueue, launchMode, useRemoteAssetBundles, container.WebRequestsContainer.WebRequestController, container.LoadingStatus, container.GltfContainerAssetsCache, appArgs, componentsContainer.ComponentPoolsRegistry.RootContainerTransform()), @@ -317,7 +322,7 @@ public UniTask InitializeAsync(StaticSettings settings, CancellationToken ct) container.SceneRestrictionBusController, web3IdentityProvider), new PointerInputAudioPlugin(container.assetsProvisioner), new MapPinPlugin(globalWorld, container.MapPinsEventBus), - new MultiplayerPlugin(), + new MultiplayerPlugin(globalWorld, playerEntity, container.CharacterDataPropagationUtility), new RealmInfoPlugin(container.RealmData, container.RoomHubProxy), new InputModifierPlugin(globalWorld, container.PlayerEntity, container.SceneRestrictionBusController), new MainCameraPlugin(componentsContainer.ComponentPoolsRegistry, container.assetsProvisioner, container.CacheCleaner, exposedGlobalDataContainer.ExposedCameraData, container.SceneRestrictionBusController, globalWorld), diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/Scene/SceneEcsExecutor.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/Scene/SceneEcsExecutor.cs index 2ecaea687d0..868c86cdc04 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/Scene/SceneEcsExecutor.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/Scene/SceneEcsExecutor.cs @@ -1,5 +1,4 @@ using Arch.Core; -using Utility.Multithreading; namespace SceneRunner.Scene { diff --git a/Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs b/Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs new file mode 100644 index 00000000000..418dddea609 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DCL.EditMode.Tests")] +[assembly: InternalsVisibleTo("DCL.PlayMode.Tests")] diff --git a/Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs.meta b/Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs.meta new file mode 100644 index 00000000000..af2846c90dc --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8a09713d5cd758149a0698befcbdfcce \ No newline at end of file diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs index 91ae5985c77..7b65a1d2ffa 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs @@ -18,7 +18,6 @@ namespace DCL.Multiplayer.SDK.Systems.GlobalWorld { - // Currently implemented to track reserved entities only on the CURRENT SCENE [UpdateInGroup(typeof(PresentationSystemGroup))] [UpdateAfter(typeof(MultiplayerProfilesSystem))] [LogCategory(ReportCategory.PLAYER_SDK_DATA)] @@ -73,23 +72,34 @@ private void ModifyPlayerScene(in CharacterTransform characterTransform, ref Pla private void ResolvePlayerCRDTScene(in CharacterTransform characterTransform, ref PlayerCRDTEntity globalPlayerCRDTEntity, CRDTEntity reservedEntityId) { bool newSceneIsValid = scenesCache.TryGetByParcel(characterTransform.Transform.ParcelPosition(), out ISceneFacade currentScene) - && currentScene.SceneStateProvider.State == SceneState.Running + && currentScene.SceneStateProvider.State.Value() is SceneState.Running or SceneState.Starting && !currentScene.IsEmpty; if (globalPlayerCRDTEntity.SceneFacade != currentScene) { - // Only try to remove component if we have a valid scene world entity - if (globalPlayerCRDTEntity.SceneWorldEntity != Entity.Null) + if (globalPlayerCRDTEntity.SceneWorldEntity != Entity.Null + && globalPlayerCRDTEntity.SceneFacade is not null) { - RemoveComponent(globalPlayerCRDTEntity.SceneWorldEntity, ref globalPlayerCRDTEntity, false); + RemovePlayerFromScene(globalPlayerCRDTEntity.SceneWorldEntity, reservedEntityId, globalPlayerCRDTEntity.SceneFacade); } if (newSceneIsValid) { SceneEcsExecutor sceneEcsExecutor = currentScene.EcsExecutor; + Entity sceneWorldEntity; - Entity sceneWorldEntity = sceneEcsExecutor.World.Create(); - sceneEcsExecutor.World.Add(sceneWorldEntity, new PlayerSceneCRDTEntity(reservedEntityId)); + if (reservedEntityId.Id == SpecialEntitiesID.PLAYER_ENTITY) + { + sceneWorldEntity = currentScene.PersistentEntities.Player; + + if (!sceneEcsExecutor.World.Has(sceneWorldEntity)) + sceneEcsExecutor.World.Add(sceneWorldEntity, new PlayerSceneCRDTEntity(reservedEntityId)); + } + else + { + sceneWorldEntity = sceneEcsExecutor.World.Create(); + sceneEcsExecutor.World.Add(sceneWorldEntity, new PlayerSceneCRDTEntity(reservedEntityId)); + } globalPlayerCRDTEntity.AssignToScene(currentScene, sceneWorldEntity); } @@ -116,15 +126,9 @@ private void RemoveComponent(Entity entity, ref PlayerCRDTEntity playerCRDTEntit { if (playerCRDTEntity is { AssignedToScene: true, SceneFacade: not null }) { - // Remove from whichever scene it was added. PlayerCRDTEntity is not removed here, - // as the scene-level Writer systems need it to know which CRDT Entity to affect. - // Only post the cleanup intention if the previous scene's world is still running — - // writing to a Disposing/Disposed/error scene world races against its teardown. - if (playerCRDTEntity.SceneWorldEntity != Entity.Null - && playerCRDTEntity.SceneFacade.SceneStateProvider.State == SceneState.Running) + if (playerCRDTEntity.SceneWorldEntity != Entity.Null) { - SceneEcsExecutor sceneEcsExecutor = playerCRDTEntity.SceneFacade.EcsExecutor; - sceneEcsExecutor.World.Add(playerCRDTEntity.SceneWorldEntity); + RemovePlayerFromScene(playerCRDTEntity.SceneWorldEntity, playerCRDTEntity.CRDTEntity, playerCRDTEntity.SceneFacade); } if (noLongerExists) @@ -135,6 +139,24 @@ private void RemoveComponent(Entity entity, ref PlayerCRDTEntity playerCRDTEntit World.Remove(entity); } + private static void RemovePlayerFromScene(Entity sceneWorldEntity, CRDTEntity crdtEntity, ISceneFacade sceneFacade) + { + bool isLocalPlayer = crdtEntity.Id == SpecialEntitiesID.PLAYER_ENTITY; + + if (sceneFacade.SceneStateProvider.State != SceneState.Running) + return; + + SceneEcsExecutor executor = sceneFacade.EcsExecutor; + + if (isLocalPlayer) + { + if (executor.World.Has(sceneWorldEntity)) + executor.World.Remove(sceneWorldEntity); + } + else + executor.World.Add(sceneWorldEntity); + } + private int ReserveNextFreeEntity() { // All reserved entities are taken diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs index fdd9c80819c..9352e0c0fff 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs @@ -2,6 +2,7 @@ using Arch.System; using Arch.SystemGroups; using Arch.SystemGroups.DefaultSystemGroups; +using DCL.Character.Components; using DCL.Diagnostics; using DCL.Multiplayer.SDK.Components; using DCL.Profiles; @@ -28,7 +29,7 @@ protected override void Update(float t) } [Query] - [None(typeof(DeleteEntityIntention))] + [None(typeof(DeleteEntityIntention), typeof(PlayerComponent))] private void PropagateProfileToScene(Profile profile, in PlayerCRDTEntity playerCRDTEntity) { if ((playerCRDTEntity.IsDirty || profile.IsDirty) && playerCRDTEntity.AssignedToScene) diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs new file mode 100644 index 00000000000..a0b0f8d1d43 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs @@ -0,0 +1,76 @@ +using Arch.Core; +using Arch.SystemGroups; +using CRDT; +using CrdtEcsBridge.Components; +using DCL.Diagnostics; +using DCL.Multiplayer.SDK.Components; +using DCL.Multiplayer.SDK.Systems.GlobalWorld; +using DCL.PluginSystem.World; +using DCL.Profiles; +using ECS.Abstract; +using ECS.Groups; +using SceneRunner.Scene; + +namespace DCL.Multiplayer.SDK.Systems.SceneWorld +{ + /// + /// Propagates the local player's identity and avatar data to every scene world unconditionally, + /// similar to how handles transforms. + /// Initialize() seeds PlayerSceneCRDTEntity and SDKProfile onto PersistentEntities.Player before the JS + /// runtime starts, so that , , + /// and can flush CRDT messages in their own Initialize(). + /// Update() keeps the SDKProfile in sync when Profile.IsDirty — unlike + /// which only targets the assigned scene + /// and which skips the local player entirely (filtered by PlayerComponent). + /// Runs in SyncedPreRenderingSystemGroup before the writer systems so SDKProfile + /// is up-to-date when they flush CRDT. Profile.IsDirty is guaranteed to be set + /// (set in PresentationSystemGroup, reset later in the same PreRenderingSystemGroup). + /// + [UpdateInGroup(typeof(SyncedPreRenderingSystemGroup))] + [UpdateBefore(typeof(WritePlayerIdentityDataSystem))] + [UpdateBefore(typeof(WriteSDKAvatarBaseSystem))] + [UpdateBefore(typeof(WriteAvatarEquippedDataSystem))] + [LogCategory(ReportCategory.PLAYER_SDK_DATA)] + public partial class LocalPlayerCRDTEntityHandlerSystem : BaseUnityLoopSystem + { + private readonly World globalWorld; + private readonly Entity localPlayerEntity; + private readonly CharacterDataPropagationUtility characterDataPropagationUtility; + private readonly PersistentEntities persistentEntities; + + internal LocalPlayerCRDTEntityHandlerSystem( + World world, + World globalWorld, + Entity localPlayerEntity, + CharacterDataPropagationUtility characterDataPropagationUtility, + PersistentEntities persistentEntities) : base(world) + { + this.globalWorld = globalWorld; + this.localPlayerEntity = localPlayerEntity; + this.characterDataPropagationUtility = characterDataPropagationUtility; + this.persistentEntities = persistentEntities; + } + + public override void Initialize() + { + if (!globalWorld.TryGet(localPlayerEntity, out Profile? profile)) + return; + + Entity playerEntity = persistentEntities.Player; + World.Add(playerEntity, new PlayerSceneCRDTEntity(new CRDTEntity(SpecialEntitiesID.PLAYER_ENTITY))); + + characterDataPropagationUtility.CopyProfileToSceneEntity(profile!, new SceneEcsExecutor(World), playerEntity); + } + + protected override void Update(float t) + { + if (!globalWorld.TryGet(localPlayerEntity, out Profile? profile)) + return; + + if (!profile!.IsDirty) + return; + + characterDataPropagationUtility.CopyProfileToSceneEntity(profile, new SceneEcsExecutor(World), persistentEntities.Player); + } + } +} diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs.meta b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs.meta new file mode 100644 index 00000000000..1d167034453 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 647be54c9faa59c41b35f0da2557aa2a \ No newline at end of file diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/WriteSDKAvatarBaseSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/WriteSDKAvatarBaseSystem.cs index 19a7041d05d..ff9313f626c 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/WriteSDKAvatarBaseSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/WriteSDKAvatarBaseSystem.cs @@ -27,17 +27,22 @@ public WriteSDKAvatarBaseSystem(World world, IECSToCRDTWriter ecsToCRDTWriter) : this.ecsToCRDTWriter = ecsToCRDTWriter; } + public override void Initialize() + { + UpdateAvatarBaseQuery(World, true); + } + protected override void Update(float t) { HandleComponentRemovalQuery(World); - UpdateAvatarBaseQuery(World); + UpdateAvatarBaseQuery(World, false); } [Query] [None(typeof(DeleteEntityIntention))] - private void UpdateAvatarBase(PlayerSceneCRDTEntity playerCRDTEntity, SDKProfile profile) + private void UpdateAvatarBase([Data] bool force, PlayerSceneCRDTEntity playerCRDTEntity, SDKProfile profile) { - if (!profile.IsDirty) return; + if (!force && !profile.IsDirty) return; ecsToCRDTWriter.PutMessage(static (pbComponent, profile) => { diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs index a89f5534fd6..ce56e1d93b2 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs @@ -98,6 +98,9 @@ public void SetupPlayerCRDTEntityForPlayerInsideScene(bool isMainPlayer) Assert.IsTrue(world.TryGet(entity, out PlayerCRDTEntity playerCRDTEntity)); Assert.IsTrue(scene1World.TryGet(playerCRDTEntity.SceneWorldEntity, out PlayerSceneCRDTEntity scenePlayerCRDTEntity)); Assert.AreEqual(playerCRDTEntity.CRDTEntity, scenePlayerCRDTEntity.CRDTEntity); + + if (isMainPlayer) + Assert.AreEqual(scene1Facade.PersistentEntities.Player, playerCRDTEntity.SceneWorldEntity); } [TestCase(true)] @@ -146,8 +149,19 @@ public void RemovePlayerCRDTEntityForPlayersLeavingScene(bool isMainPlayer) Assert.IsTrue(world.TryGet(entity, out PlayerCRDTEntity newState)); Assert.IsFalse(newState.AssignedToScene); - Assert.IsTrue(playerCRDTEntity.SceneFacade.EcsExecutor.World.Has(playerCRDTEntity.SceneWorldEntity)); - Assert.That(playerCRDTEntity.SceneFacade.EcsExecutor.World.Has(playerCRDTEntity.SceneWorldEntity), Is.True); + + if (isMainPlayer) + { + // Local player: PersistentEntities.Player has PlayerSceneCRDTEntity removed, not destroyed + Assert.IsFalse(scene1World.Has(playerCRDTEntity.SceneWorldEntity)); + Assert.IsFalse(scene1World.Has(playerCRDTEntity.SceneWorldEntity)); + } + else + { + // Remote player: separate entity gets DeleteEntityIntention + Assert.IsTrue(playerCRDTEntity.SceneFacade.EcsExecutor.World.Has(playerCRDTEntity.SceneWorldEntity)); + Assert.That(playerCRDTEntity.SceneFacade.EcsExecutor.World.Has(playerCRDTEntity.SceneWorldEntity), Is.True); + } } [TestCase(true)] @@ -180,7 +194,21 @@ public void ChangeSceneOnPlayerMove(bool isMainPlayer) Assert.IsTrue(world.TryGet(entity, out playerCRDTEntity)); Assert.That(playerCRDTEntity.SceneFacade, Is.EqualTo(scene2Facade)); Assert.IsTrue(scene2Facade.EcsExecutor.World.Has(playerCRDTEntity.SceneWorldEntity)); - Assert.That(scene1Facade.EcsExecutor.World.Has(scene1Entity), Is.True); + + if (isMainPlayer) + { + // Local player: old scene's persistent player loses PlayerSceneCRDTEntity (not destroyed) + Assert.IsFalse(scene1Facade.EcsExecutor.World.Has(scene1Entity)); + Assert.IsFalse(scene1Facade.EcsExecutor.World.Has(scene1Entity)); + + // New scene uses its persistent player entity + Assert.AreEqual(scene2Facade.PersistentEntities.Player, playerCRDTEntity.SceneWorldEntity); + } + else + { + // Remote player: old entity gets DeleteEntityIntention + Assert.That(scene1Facade.EcsExecutor.World.Has(scene1Entity), Is.True); + } } [Test] @@ -344,9 +372,10 @@ public void SkipSceneSideCleanupWhenPreviousSceneIsDisposing() Assert.IsTrue(world.TryGet(entity, out playerCRDTEntity)); Assert.IsFalse(playerCRDTEntity.AssignedToScene); - // Scene-side cleanup write must be skipped: posting DeleteEntityIntention to a - // disposing world would race with its teardown. + // Scene-side cleanup write must be skipped: RemovePlayerFromScene gates on Running, + // so neither DeleteEntityIntention nor PlayerSceneCRDTEntity removal should happen. Assert.That(scene1World.Has(scene1Entity), Is.False); + Assert.That(scene1World.Has(scene1Entity), Is.True); } [Test] diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs new file mode 100644 index 00000000000..a4527e051a3 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs @@ -0,0 +1,84 @@ +using Arch.Core; +using CrdtEcsBridge.Components; +using DCL.Multiplayer.SDK.Components; +using DCL.Multiplayer.SDK.Systems.GlobalWorld; +using DCL.Multiplayer.SDK.Systems.SceneWorld; +using DCL.Optimization.Pools; +using DCL.PluginSystem.World; +using DCL.Profiles; +using ECS.TestSuite; +using NSubstitute; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace DCL.Multiplayer.SDK.Tests +{ + public class LocalPlayerCRDTEntityHandlerSystemShould : UnitySystemTestBase + { + private const string FAKE_USER_ID = "Ia4Ia5Cth0ulhu2Ftaghn2"; + + private World globalWorld; + private Entity localPlayerEntity; + private Entity persistentPlayerEntity; + private PersistentEntities persistentEntities; + private CharacterDataPropagationUtility characterDataPropagationUtility; + + [OneTimeSetUp] + public void OneTimeSetUp() => + EcsTestsUtils.SetUpFeaturesRegistry(); + + [OneTimeTearDown] + public void OneTimeTearDown() => + EcsTestsUtils.TearDownFeaturesRegistry(); + + [SetUp] + public void Setup() + { + globalWorld = World.Create(); + + localPlayerEntity = globalWorld.Create( + Profile.NewRandomProfile(FAKE_USER_ID) + ); + + IComponentPool pool = Substitute.For>(); + pool.Get().Returns(_ => new SDKProfile()); + characterDataPropagationUtility = new CharacterDataPropagationUtility(pool); + + persistentPlayerEntity = world.Create(); + persistentEntities = new PersistentEntities(persistentPlayerEntity, Entity.Null, Entity.Null, Entity.Null); + + system = new LocalPlayerCRDTEntityHandlerSystem( + world, globalWorld, localPlayerEntity, + characterDataPropagationUtility, persistentEntities); + } + + protected override void OnTearDown() + { + globalWorld.Dispose(); + } + + [Test] + public void SeedPlayerCRDTEntityAndProfile() + { + system.Initialize(); + + Assert.IsTrue(world.Has(persistentPlayerEntity)); + Assert.IsTrue(world.TryGet(persistentPlayerEntity, out PlayerSceneCRDTEntity crdtEntity)); + Assert.AreEqual(SpecialEntitiesID.PLAYER_ENTITY, crdtEntity.CRDTEntity.Id); + + Assert.IsTrue(world.TryGet(persistentPlayerEntity, out SDKProfile sdkProfile)); + Assert.AreEqual(FAKE_USER_ID, sdkProfile!.UserId); + } + + [Test] + public void NotSeedWhenPlayerHasNoProfile() + { + globalWorld.Remove(localPlayerEntity); + + system.Initialize(); + + Assert.IsFalse(world.Has(persistentPlayerEntity)); + } + } +} diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs.meta b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs.meta new file mode 100644 index 00000000000..cda1056cf14 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/SeedLocalPlayerCRDTSystemShould.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 098f6326ca42e924bbf989e6d7b7d093 \ No newline at end of file diff --git a/Explorer/Assets/DCL/PluginSystem/World/MultiplayerPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/MultiplayerPlugin.cs index bbcfc051eb1..b834e6ae5c5 100644 --- a/Explorer/Assets/DCL/PluginSystem/World/MultiplayerPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/World/MultiplayerPlugin.cs @@ -1,4 +1,6 @@ +using Arch.Core; using Arch.SystemGroups; +using DCL.Multiplayer.SDK.Systems.GlobalWorld; using DCL.Multiplayer.SDK.Systems.SceneWorld; using DCL.PluginSystem.World.Dependencies; using ECS.LifeCycle; @@ -12,13 +14,26 @@ namespace DCL.PluginSystem.World { public class MultiplayerPlugin : IDCLWorldPluginWithoutSettings { - public void Dispose() + private readonly Arch.Core.World globalWorld; + private readonly Entity localPlayerEntity; + private readonly CharacterDataPropagationUtility characterDataPropagationUtility; + + public MultiplayerPlugin( + Arch.Core.World globalWorld, + Entity localPlayerEntity, + CharacterDataPropagationUtility characterDataPropagationUtility) { - //ignore + this.globalWorld = globalWorld; + this.localPlayerEntity = localPlayerEntity; + this.characterDataPropagationUtility = characterDataPropagationUtility; } + public void Dispose() { } + public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in ECSWorldInstanceSharedDependencies sharedDependencies, in PersistentEntities persistentEntities, List finalizeWorldSystems, List sceneIsCurrentListeners) { + LocalPlayerCRDTEntityHandlerSystem.InjectToWorld(ref builder, globalWorld, localPlayerEntity, characterDataPropagationUtility, persistentEntities); + WritePlayerIdentityDataSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter); WriteSDKAvatarBaseSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter); WriteAvatarEquippedDataSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter); From 574114265bac7ebb427af5a114a641f5377a9904 Mon Sep 17 00:00:00 2001 From: Mikhail Agapov Date: Thu, 21 May 2026 12:23:36 +0300 Subject: [PATCH 2/5] Fix RemovePlayerFromScene --- .../Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs index 7b65a1d2ffa..94961d27c70 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs @@ -143,7 +143,9 @@ private static void RemovePlayerFromScene(Entity sceneWorldEntity, CRDTEntity cr { bool isLocalPlayer = crdtEntity.Id == SpecialEntitiesID.PLAYER_ENTITY; - if (sceneFacade.SceneStateProvider.State != SceneState.Running) + SceneState state = sceneFacade.SceneStateProvider.State.Value(); + + if (state != SceneState.Running && state != SceneState.Starting) return; SceneEcsExecutor executor = sceneFacade.EcsExecutor; From 6c2dc08f3e937485b9b27f9018a37ba846571334 Mon Sep 17 00:00:00 2001 From: Mikhail Agapov Date: Thu, 21 May 2026 12:41:23 +0300 Subject: [PATCH 3/5] @ Update tests to expect player assignment during SceneState.Starting The system now assigns players to scenes in the Starting state, so tests asserting the opposite are updated to match. Co-Authored-By: Claude Opus 4.6 (1M context) @ --- .../PlayerCRDTEntitiesHandlerSystemShould.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs index ce56e1d93b2..25555696449 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs @@ -291,7 +291,7 @@ public void TrackReservedCRDTEntityIdsCorrectly() [TestCase(true)] [TestCase(false)] - public void NotAssignPlayerWhenSceneIsStarting(bool isMainPlayer) + public void AssignPlayerWhenSceneIsStarting(bool isMainPlayer) { scene1Facade.SceneStateProvider.State.Returns(new Atomic(SceneState.Starting)); @@ -307,14 +307,14 @@ public void NotAssignPlayerWhenSceneIsStarting(bool isMainPlayer) system.Update(0); Assert.IsTrue(world.TryGet(entity, out PlayerCRDTEntity playerCRDTEntity)); - Assert.IsFalse(playerCRDTEntity.AssignedToScene); - Assert.That(playerCRDTEntity.SceneFacade, Is.Null); - Assert.That(playerCRDTEntity.SceneWorldEntity, Is.EqualTo(Entity.Null)); + Assert.IsTrue(playerCRDTEntity.AssignedToScene); + Assert.That(playerCRDTEntity.SceneFacade, Is.EqualTo(scene1Facade)); + Assert.IsTrue(scene1World.Has(playerCRDTEntity.SceneWorldEntity)); } [TestCase(true)] [TestCase(false)] - public void AssignPlayerWhenSceneTransitionsFromStartingToRunning(bool isMainPlayer) + public void KeepPlayerAssignedWhenSceneTransitionsFromStartingToRunning(bool isMainPlayer) { scene1Facade.SceneStateProvider.State.Returns(new Atomic(SceneState.Starting)); @@ -327,16 +327,16 @@ public void AssignPlayerWhenSceneTransitionsFromStartingToRunning(bool isMainPla if (isMainPlayer) world.Add(entity, new PlayerComponent()); - // First tick: scene still Starting — gate blocks assignment + // First tick: scene is Starting — player is assigned immediately system.Update(0); Assert.IsTrue(world.TryGet(entity, out PlayerCRDTEntity playerCRDTEntity)); - Assert.IsFalse(playerCRDTEntity.AssignedToScene); + Assert.IsTrue(playerCRDTEntity.AssignedToScene); // Scene finishes initializing scene1Facade.SceneStateProvider.State.Returns(new Atomic(SceneState.Running)); - // Next tick: reconciliation query auto-retries and assigns + // Next tick: assignment persists through state transition system.Update(0); Assert.IsTrue(world.TryGet(entity, out playerCRDTEntity)); From ea34e551bb4470fdd03265c9c991dea9641ae5c7 Mon Sep 17 00:00:00 2001 From: Mikhail Agapov Date: Thu, 21 May 2026 15:08:13 +0300 Subject: [PATCH 4/5] Handle Local Player more carefully & clean-up some paths --- .../CharacterDataPropagationUtility.cs | 8 ++-- .../PlayerCRDTEntitiesHandlerSystem.cs | 41 ++++++------------- .../PlayerProfileDataPropagationSystem.cs | 2 +- .../PlayerTransformPropagationSystem.cs | 2 +- .../LocalPlayerCRDTEntityHandlerSystem.cs | 8 ++-- 5 files changed, 22 insertions(+), 39 deletions(-) diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/CharacterDataPropagationUtility.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/CharacterDataPropagationUtility.cs index 6f31db4d4df..fd299f798ce 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/CharacterDataPropagationUtility.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/CharacterDataPropagationUtility.cs @@ -23,16 +23,16 @@ public void PropagateGlobalPlayerToScenePlayer(World globalWorld, Entity globalP { Entity targetEntity = sceneFacade.PersistentEntities.Player; - CopyProfileToSceneEntity(globalWorld.Get(globalPlayerEntity), sceneFacade.EcsExecutor, targetEntity); + CopyProfileToSceneEntity(globalWorld.Get(globalPlayerEntity), sceneFacade.EcsExecutor.World, targetEntity); sceneFacade.EcsExecutor.World.Add(targetEntity, new PlayerSceneCRDTEntity(SpecialEntitiesID.PLAYER_ENTITY)); } - public void CopyProfileToSceneEntity(Profile profile, SceneEcsExecutor sceneEcsExecutor, Entity sceneEntity) + public void CopyProfileToSceneEntity(Profile profile, World sceneWorld, Entity sceneEntity) { - if (!sceneEcsExecutor.World.TryGet(sceneEntity, out SDKProfile? profileSDKSubProduct)) + if (!sceneWorld.TryGet(sceneEntity, out SDKProfile? profileSDKSubProduct)) { profileSDKSubProduct = profileSDKSubProductPool.Get(); - sceneEcsExecutor.World.Add(sceneEntity, profileSDKSubProduct); + sceneWorld.Add(sceneEntity, profileSDKSubProduct); } profileSDKSubProduct!.OverrideWith(profile); diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs index 94961d27c70..e2510a1e24b 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs @@ -78,32 +78,23 @@ private void ResolvePlayerCRDTScene(in CharacterTransform characterTransform, re if (globalPlayerCRDTEntity.SceneFacade != currentScene) { if (globalPlayerCRDTEntity.SceneWorldEntity != Entity.Null - && globalPlayerCRDTEntity.SceneFacade is not null) - { - RemovePlayerFromScene(globalPlayerCRDTEntity.SceneWorldEntity, reservedEntityId, globalPlayerCRDTEntity.SceneFacade); - } + && globalPlayerCRDTEntity.SceneFacade is not null) { RemovePlayerFromScene(globalPlayerCRDTEntity.SceneWorldEntity, reservedEntityId, globalPlayerCRDTEntity.SceneFacade); } if (newSceneIsValid) { SceneEcsExecutor sceneEcsExecutor = currentScene.EcsExecutor; - Entity sceneWorldEntity; - if (reservedEntityId.Id == SpecialEntitiesID.PLAYER_ENTITY) - { - sceneWorldEntity = currentScene.PersistentEntities.Player; + bool isLocalPlayer = reservedEntityId.Id == SpecialEntitiesID.PLAYER_ENTITY; - if (!sceneEcsExecutor.World.Has(sceneWorldEntity)) - sceneEcsExecutor.World.Add(sceneWorldEntity, new PlayerSceneCRDTEntity(reservedEntityId)); - } - else - { - sceneWorldEntity = sceneEcsExecutor.World.Create(); - sceneEcsExecutor.World.Add(sceneWorldEntity, new PlayerSceneCRDTEntity(reservedEntityId)); - } + // LocalPlayerCRDTEntityHandlerSystem creates PlayerSceneCRDTEntity on scene start-up + Entity sceneWorldEntity = isLocalPlayer + ? currentScene.PersistentEntities.Player + : sceneEcsExecutor.World.Create(new PlayerSceneCRDTEntity(reservedEntityId)); globalPlayerCRDTEntity.AssignToScene(currentScene, sceneWorldEntity); } - else { globalPlayerCRDTEntity.RemoveFromScene(); } + else + globalPlayerCRDTEntity.RemoveFromScene(); } } @@ -127,9 +118,7 @@ private void RemoveComponent(Entity entity, ref PlayerCRDTEntity playerCRDTEntit if (playerCRDTEntity is { AssignedToScene: true, SceneFacade: not null }) { if (playerCRDTEntity.SceneWorldEntity != Entity.Null) - { RemovePlayerFromScene(playerCRDTEntity.SceneWorldEntity, playerCRDTEntity.CRDTEntity, playerCRDTEntity.SceneFacade); - } if (noLongerExists) FreeReservedEntity(playerCRDTEntity.CRDTEntity.Id); @@ -141,22 +130,16 @@ private void RemoveComponent(Entity entity, ref PlayerCRDTEntity playerCRDTEntit private static void RemovePlayerFromScene(Entity sceneWorldEntity, CRDTEntity crdtEntity, ISceneFacade sceneFacade) { - bool isLocalPlayer = crdtEntity.Id == SpecialEntitiesID.PLAYER_ENTITY; + // Local Player is never removed from the scene world + if (crdtEntity.Id == SpecialEntitiesID.PLAYER_ENTITY) + return; SceneState state = sceneFacade.SceneStateProvider.State.Value(); if (state != SceneState.Running && state != SceneState.Starting) return; - SceneEcsExecutor executor = sceneFacade.EcsExecutor; - - if (isLocalPlayer) - { - if (executor.World.Has(sceneWorldEntity)) - executor.World.Remove(sceneWorldEntity); - } - else - executor.World.Add(sceneWorldEntity); + sceneFacade.EcsExecutor.World.Add(sceneWorldEntity); } private int ReserveNextFreeEntity() diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs index 9352e0c0fff..13e1dfc3781 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerProfileDataPropagationSystem.cs @@ -33,7 +33,7 @@ protected override void Update(float t) private void PropagateProfileToScene(Profile profile, in PlayerCRDTEntity playerCRDTEntity) { if ((playerCRDTEntity.IsDirty || profile.IsDirty) && playerCRDTEntity.AssignedToScene) - characterDataPropagationUtility.CopyProfileToSceneEntity(profile, playerCRDTEntity.SceneFacade!.EcsExecutor, playerCRDTEntity.SceneWorldEntity); + characterDataPropagationUtility.CopyProfileToSceneEntity(profile, playerCRDTEntity.SceneFacade!.EcsExecutor.World, playerCRDTEntity.SceneWorldEntity); } } } diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerTransformPropagationSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerTransformPropagationSystem.cs index 98749d7e9a2..a1ae32c25bb 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerTransformPropagationSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerTransformPropagationSystem.cs @@ -43,7 +43,7 @@ private void PropagateTransformToScene(in CharacterTransform characterTransform, World sceneEcsWorld = playerCRDTEntity.SceneFacade!.EcsExecutor.World; // Position is updated to scene-relative on the writer system - if (!sceneEcsWorld.TryGet(playerCRDTEntity.SceneWorldEntity, out SDKTransform? sdkTransform)) + if (!sceneEcsWorld.TryGet(playerCRDTEntity.SceneWorldEntity, out SDKTransform? sdkTransform)) sceneEcsWorld.Add(playerCRDTEntity.SceneWorldEntity, sdkTransform = sdkTransformPool.Get()); sdkTransform!.Position.Value = characterTransform.Transform.position; diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs index a0b0f8d1d43..716c33202be 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Systems/SceneWorld/LocalPlayerCRDTEntityHandlerSystem.cs @@ -53,24 +53,24 @@ internal LocalPlayerCRDTEntityHandlerSystem( public override void Initialize() { - if (!globalWorld.TryGet(localPlayerEntity, out Profile? profile)) + if (!globalWorld.TryGet(localPlayerEntity, out Profile? profile)) return; Entity playerEntity = persistentEntities.Player; World.Add(playerEntity, new PlayerSceneCRDTEntity(new CRDTEntity(SpecialEntitiesID.PLAYER_ENTITY))); - characterDataPropagationUtility.CopyProfileToSceneEntity(profile!, new SceneEcsExecutor(World), playerEntity); + characterDataPropagationUtility.CopyProfileToSceneEntity(profile!, World, playerEntity); } protected override void Update(float t) { - if (!globalWorld.TryGet(localPlayerEntity, out Profile? profile)) + if (!globalWorld.TryGet(localPlayerEntity, out Profile? profile)) return; if (!profile!.IsDirty) return; - characterDataPropagationUtility.CopyProfileToSceneEntity(profile, new SceneEcsExecutor(World), persistentEntities.Player); + characterDataPropagationUtility.CopyProfileToSceneEntity(profile, World, persistentEntities.Player); } } } From d175ddef883c8b6a01c39544291fe938df84167d Mon Sep 17 00:00:00 2001 From: Mikhail Agapov Date: Fri, 22 May 2026 12:06:24 +0300 Subject: [PATCH 5/5] @ Fix test assertions for local player PlayerSceneCRDTEntity persistence Local player PlayerSceneCRDTEntity now persists on the persistent entity after leaving a scene, matching the RemovePlayerFromScene early-return behavior from ea34e551. Co-Authored-By: Claude Opus 4.6 (1M context) @ --- .../SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs index 25555696449..2c0ad08f777 100644 --- a/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs +++ b/Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs @@ -152,9 +152,9 @@ public void RemovePlayerCRDTEntityForPlayersLeavingScene(bool isMainPlayer) if (isMainPlayer) { - // Local player: PersistentEntities.Player has PlayerSceneCRDTEntity removed, not destroyed + // Local player: PlayerSceneCRDTEntity persists so scene data remains available Assert.IsFalse(scene1World.Has(playerCRDTEntity.SceneWorldEntity)); - Assert.IsFalse(scene1World.Has(playerCRDTEntity.SceneWorldEntity)); + Assert.IsTrue(scene1World.Has(playerCRDTEntity.SceneWorldEntity)); } else { @@ -197,9 +197,9 @@ public void ChangeSceneOnPlayerMove(bool isMainPlayer) if (isMainPlayer) { - // Local player: old scene's persistent player loses PlayerSceneCRDTEntity (not destroyed) + // Local player: persistent entity retains PlayerSceneCRDTEntity (not destroyed) Assert.IsFalse(scene1Facade.EcsExecutor.World.Has(scene1Entity)); - Assert.IsFalse(scene1Facade.EcsExecutor.World.Has(scene1Entity)); + Assert.IsTrue(scene1Facade.EcsExecutor.World.Has(scene1Entity)); // New scene uses its persistent player entity Assert.AreEqual(scene2Facade.PersistentEntities.Player, playerCRDTEntity.SceneWorldEntity);