Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ await MapRendererContainer

dynamicWorldDependencies.WorldInfoTool.Initialize(worldInfoHub);

var characterDataPropagationUtility = new CharacterDataPropagationUtility(staticContainer.ComponentsContainer.ComponentPoolsRegistry.AddComponentPool<SDKProfile>());
CharacterDataPropagationUtility characterDataPropagationUtility = staticContainer.CharacterDataPropagationUtility;

var currentSceneInfo = new CurrentSceneInfo();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,6 +135,7 @@ public class StaticContainer : IDCLPlugin<StaticSettings>

public IGltfContainerAssetsCache GltfContainerAssetsCache { get; private set; }
public AssetPreLoadCache AssetPreLoadCache { get; private set; }
public CharacterDataPropagationUtility CharacterDataPropagationUtility { get; private set; }

public void Dispose()
{
Expand Down Expand Up @@ -281,6 +283,9 @@ public UniTask InitializeAsync(StaticSettings settings, CancellationToken ct)

var promisesAnalyticsPlugin = new PromisesAnalyticsPlugin(debugContainerBuilder);

container.CharacterDataPropagationUtility = new CharacterDataPropagationUtility(
componentsContainer.ComponentPoolsRegistry.AddComponentPool<SDKProfile>());

container.ECSWorldPlugins = new IDCLWorldPlugin[]
{
new GltfContainerPlugin(sharedDependencies, container.CacheCleaner, container.SceneReadinessReportQueue, launchMode, useRemoteAssetBundles, container.WebRequestsContainer.WebRequestController, container.LoadingStatus, container.GltfContainerAssetsCache, appArgs, componentsContainer.ComponentPoolsRegistry.RootContainerTransform()),
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Arch.Core;
using Utility.Multithreading;

namespace SceneRunner.Scene
{
Expand Down
4 changes: 4 additions & 0 deletions Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("DCL.EditMode.Tests")]
[assembly: InternalsVisibleTo("DCL.PlayMode.Tests")]
2 changes: 2 additions & 0 deletions Explorer/Assets/DCL/Multiplayer/AssemblyInfo.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failing tests not updated for this behavior change.

SceneState.Starting is now accepted as valid for assignment, but two pre-existing tests assert the opposite behavior and are not updated by this PR:

  1. NotAssignPlayerWhenSceneIsStarting (lines 293–313) — asserts AssignedToScene = false when the scene is Starting, but this code will now set it to true. Both isMainPlayer = true and isMainPlayer = false variants fail.

  2. AssignPlayerWhenSceneTransitionsFromStartingToRunning (lines 315–346) — the first Update() tick (scene still Starting) now assigns the player, so Assert.IsFalse(playerCRDTEntity.AssignedToScene) at line 334 fails.

These tests need to be updated to cover the new semantics. For NotAssignPlayerWhenSceneIsStarting, the test should be renamed and updated to verify that the player is assigned when the scene is Starting. For AssignPlayerWhenSceneTransitionsFromStartingToRunning, the intermediate assertion after the first tick should be removed or changed to IsTrue.

Fix this →

&& !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<PlayerSceneCRDTEntity>(sceneWorldEntity))
sceneEcsExecutor.World.Add(sceneWorldEntity, new PlayerSceneCRDTEntity(reservedEntityId));
}
else
{
sceneWorldEntity = sceneEcsExecutor.World.Create();
sceneEcsExecutor.World.Add(sceneWorldEntity, new PlayerSceneCRDTEntity(reservedEntityId));
}
Comment thread
pravusjif marked this conversation as resolved.
Outdated

globalPlayerCRDTEntity.AssignToScene(currentScene, sceneWorldEntity);
}
Expand All @@ -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<DeleteEntityIntention>(playerCRDTEntity.SceneWorldEntity);
RemovePlayerFromScene(playerCRDTEntity.SceneWorldEntity, playerCRDTEntity.CRDTEntity, playerCRDTEntity.SceneFacade);
}

if (noLongerExists)
Expand All @@ -135,6 +139,26 @@ private void RemoveComponent(Entity entity, ref PlayerCRDTEntity playerCRDTEntit
World.Remove<PlayerCRDTEntity>(entity);
}

private static void RemovePlayerFromScene(Entity sceneWorldEntity, CRDTEntity crdtEntity, ISceneFacade sceneFacade)
{
bool isLocalPlayer = crdtEntity.Id == SpecialEntitiesID.PLAYER_ENTITY;

SceneState state = sceneFacade.SceneStateProvider.State.Value();

if (state != SceneState.Running && state != SceneState.Starting)
return;

SceneEcsExecutor executor = sceneFacade.EcsExecutor;

if (isLocalPlayer)
{
if (executor.World.Has<PlayerSceneCRDTEntity>(sceneWorldEntity))
executor.World.Remove<PlayerSceneCRDTEntity>(sceneWorldEntity);
}
else
executor.World.Add<DeleteEntityIntention>(sceneWorldEntity);
}

private int ReserveNextFreeEntity()
{
// All reserved entities are taken
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Propagates the local player's identity and avatar data to every scene world unconditionally,
/// similar to how <see cref="DCL.CharacterMotion.Systems.WriteMainPlayerTransformSystem" /> handles transforms.
/// Initialize() seeds PlayerSceneCRDTEntity and SDKProfile onto PersistentEntities.Player before the JS
/// runtime starts, so that <see cref="WritePlayerIdentityDataSystem" />, <see cref="WriteSDKAvatarBaseSystem" />,
/// and <see cref="WriteAvatarEquippedDataSystem" /> can flush CRDT messages in their own Initialize().
/// Update() keeps the SDKProfile in sync when Profile.IsDirty — unlike
/// <see cref="PlayerProfileDataPropagationSystem" /> 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).
/// </summary>
[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<Profile>(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);
Comment thread
pravusjif marked this conversation as resolved.
Outdated
}

protected override void Update(float t)
{
if (!globalWorld.TryGet<Profile>(localPlayerEntity, out Profile? profile))
return;

if (!profile!.IsDirty)
return;

characterDataPropagationUtility.CopyProfileToSceneEntity(profile, new SceneEcsExecutor(World), persistentEntities.Player);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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<PBAvatarBase, SDKProfile>(static (pbComponent, profile) =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<PlayerSceneCRDTEntity>(playerCRDTEntity.SceneWorldEntity));
Assert.That(playerCRDTEntity.SceneFacade.EcsExecutor.World.Has<DeleteEntityIntention>(playerCRDTEntity.SceneWorldEntity), Is.True);

if (isMainPlayer)
{
// Local player: PersistentEntities.Player has PlayerSceneCRDTEntity removed, not destroyed
Assert.IsFalse(scene1World.Has<DeleteEntityIntention>(playerCRDTEntity.SceneWorldEntity));
Assert.IsFalse(scene1World.Has<PlayerSceneCRDTEntity>(playerCRDTEntity.SceneWorldEntity));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING — test assertion contradicts current implementation.

Commit ea34e551 removed the World.Remove<PlayerSceneCRDTEntity> call from RemovePlayerFromScene for the local player:

// Local Player is never removed from the scene world
if (crdtEntity.Id == SpecialEntitiesID.PLAYER_ENTITY)
    return;

With that early return, PlayerSceneCRDTEntity is never removed from PersistentEntities.Player when the local player leaves a scene. The assertion below will therefore always fail (component is still present):

Assert.IsFalse(scene1World.Has<PlayerSceneCRDTEntity>(playerCRDTEntity.SceneWorldEntity)); // FAILS

Per the PR's stated intent ("user-related data will be available for the scene even if it's not current"), the expected behavior is now that the component persists. Fix: change to Assert.IsTrue and update the comment on line 154 accordingly.

Fix this →

}
else
{
// Remote player: separate entity gets DeleteEntityIntention
Assert.IsTrue(playerCRDTEntity.SceneFacade.EcsExecutor.World.Has<PlayerSceneCRDTEntity>(playerCRDTEntity.SceneWorldEntity));
Assert.That(playerCRDTEntity.SceneFacade.EcsExecutor.World.Has<DeleteEntityIntention>(playerCRDTEntity.SceneWorldEntity), Is.True);
}
}

[TestCase(true)]
Expand Down Expand Up @@ -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<PlayerSceneCRDTEntity>(playerCRDTEntity.SceneWorldEntity));
Assert.That(scene1Facade.EcsExecutor.World.Has<DeleteEntityIntention>(scene1Entity), Is.True);

if (isMainPlayer)
{
// Local player: old scene's persistent player loses PlayerSceneCRDTEntity (not destroyed)
Assert.IsFalse(scene1Facade.EcsExecutor.World.Has<DeleteEntityIntention>(scene1Entity));
Assert.IsFalse(scene1Facade.EcsExecutor.World.Has<PlayerSceneCRDTEntity>(scene1Entity));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING — same issue as line 157. RemovePlayerFromScene returns early for local player without removing PlayerSceneCRDTEntity, so scene1Entity (which is scene1Facade.PersistentEntities.Player) still has the component after the player moves to scene2. Assert.IsFalse will fail. Change to Assert.IsTrue and update the comment on line 200 to say the persistent entity retains PlayerSceneCRDTEntity.


// 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<DeleteEntityIntention>(scene1Entity), Is.True);
}
}

[Test]
Expand Down Expand Up @@ -263,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>(SceneState.Starting));

Expand All @@ -279,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<PlayerSceneCRDTEntity>(playerCRDTEntity.SceneWorldEntity));
}

[TestCase(true)]
[TestCase(false)]
public void AssignPlayerWhenSceneTransitionsFromStartingToRunning(bool isMainPlayer)
public void KeepPlayerAssignedWhenSceneTransitionsFromStartingToRunning(bool isMainPlayer)
{
scene1Facade.SceneStateProvider.State.Returns(new Atomic<SceneState>(SceneState.Starting));

Expand All @@ -299,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>(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));
Expand Down Expand Up @@ -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<DeleteEntityIntention>(scene1Entity), Is.False);
Assert.That(scene1World.Has<PlayerSceneCRDTEntity>(scene1Entity), Is.True);
}

[Test]
Expand Down
Loading
Loading