diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 15f400f9db..e51736865f 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -20,6 +20,9 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Issue where moving a `NetworkObject` into another scene made the clients that did not observe it log "Trying to synchronize NetworkObjectId but it was not spawned". The scene migration is now only sent to the clients that observe the `NetworkObject`. +- Issue where a `NetworkObject` that was moved into another scene while hidden from a client spawned in that client's active scene when it was shown with `NetworkShow`, instead of the scene it is in on the server. + ### Security ### Obsolete diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs index 07818501c5..9e5a7246c8 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs @@ -9,22 +9,27 @@ namespace Unity.Netcode { /// - /// TODO-UNIFIED: Needs further peer review and exploring alternate ways of handling this. /// This is a component that is added to the root of all N4E-spawned hybrid prefab instances. It is used to link /// the N4E-spawned hybrid prefab instances to the incoming /// specific to the N4E-spawned hybrid prefab instance that has the matching . /// [DefaultExecutionOrder(GhostObject.ExecutionOrder + 1)] - //BREAK --- Fix this on UNIFIED side 1st - // Internal: GhostBehaviour is only public when NETCODE_GAMEOBJECT_BRIDGE_EXPERIMENTAL is defined, and a public - // type cannot derive from an internal one. internal partial class NetworkObjectBridge : GhostBehaviour { - // DefaultExecutionOrder - // TODO: Define a const for the value used on GhostObject and use that value - // to set the execution order so if it changes on GhostObject it updates here. #if UNITY_EDITOR + private void Reset() + { + // Start users with just interpolation (they can adjust this if they want prediction) + // to make the initial transition less problematic for users. + // Only set when the bridge is first added, so a user's prediction setting is kept. + var ghostAdapter = GetComponent(); + if (ghostAdapter != null) + { + ghostAdapter.SupportedGhostModes = GhostModeMask.Interpolated; + } + } + private void OnValidate() { hideFlags = HideFlags.HideInInspector; @@ -35,10 +40,6 @@ private void OnValidate() return; } - // Start users with just interpolation (they can adjust this if they want prediction) - // to make the initial transition less problematic for users. - ghostAdapter.SupportedGhostModes = GhostModeMask.Interpolated; - #if COM_UNITY_MODULES_PHYSICS var rigidBody = GetComponent(); var ghostRigidBody = GetComponent(); @@ -88,6 +89,41 @@ internal void ApplyScale(Vector3 scale) { Ghost.ApplyPostTransformMatrixScale(scale); } + + /// + /// Keeps the ghost's owner in step with the owner, so an owner-predicted + /// ghost is predicted by the NGO owner. + /// + /// Only the server can assign a ghost owner; N4E replicates it to clients. + /// The server's . + /// The new NGO owner. + internal void UpdateGhostOwner(NetworkManager networkManager, ulong ownerClientId) + { + if (!networkManager.IsServer || !Ghost.HasOwner) + { + return; + } + + // Owned by the server with no local client means no client owns the ghost. + var ownerNetworkId = default(NetworkId); + if (ownerClientId == networkManager.LocalClientId) + { + if (networkManager.IsHost && networkManager.NetcodeWorld != null) + { + ownerNetworkId = networkManager.NetcodeWorld.LocalConnection.NetworkId; + } + } + else + { + // The unified transport id is the client's N4E network id. + var (transportId, found) = networkManager.ConnectionManager.ClientIdToTransportId(ownerClientId); + if (found) + { + ownerNetworkId = new NetworkId { Value = (int)transportId }; + } + } + Ghost.OwnerNetworkId = ownerNetworkId; + } } #if UNIFIED_NETCODE_7_0_0 diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs index 3a41f7b3a9..352bea3ab7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs @@ -3,6 +3,7 @@ #endif #if UNIFIED_NETCODE using System; +using System.Collections.Generic; using Unity.Entities; #if !UNIFIED_NETCODE_7_0_0 using Unity.NetCode; @@ -27,6 +28,32 @@ internal class UnifiedBootstrap : ClientServerBootstrap private static int s_WorldCounter = 0; + // Every ClientServerBootstrap constructor clears N4E's ServerWorlds and ClientWorlds, and each NetworkManager + // creates its own bootstrap, so the worlds created for other NetworkManagers are registered again. + private static readonly List s_CreatedWorlds = new List(); + + private static void RegisterCreatedWorlds() + { + for (int i = s_CreatedWorlds.Count - 1; i >= 0; i--) + { + var world = s_CreatedWorlds[i]; + if (!world.IsCreated) + { + s_CreatedWorlds.RemoveAt(i); + continue; + } + // A single world host is registered as both a server and a client world, the same as N4E does. + if (world.IsServer() && !ServerWorlds.Contains(world)) + { + ServerWorlds.Add(world); + } + if (world.IsClient() && !ClientWorlds.Contains(world)) + { + ClientWorlds.Add(world); + } + } + } + public override bool Initialize(string defaultWorldName) { var networkManager = CurrentNetworkManagerForInitialization; @@ -44,6 +71,7 @@ public override bool Initialize(string defaultWorldName) if (networkManager != null) { + RegisterCreatedWorlds(); Debug.Log($"Starting a world for {(networkManager.IsServer ? "Host" : "Client")}"); s_WorldCounter++; LastCreatedWorld = networkManager.IsServer ? CreateSingleWorldHost($"HostSingleWorld-{s_WorldCounter}") @@ -69,6 +97,7 @@ public override bool Initialize(string defaultWorldName) } networkManager.NetcodeWorld = (NetcodeWorld)LastCreatedWorld; + s_CreatedWorlds.Add(networkManager.NetcodeWorld); } else { diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs index 5d1a04537b..99f923b6ca 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs @@ -74,6 +74,54 @@ internal enum __RpcExecStage private const int k_RpcMessageDefaultSize = 1024; // 1k private const int k_RpcMessageMaximumSize = 1024 * 64; // 64k +#if UNIFIED_NETCODE + private bool m_WarnedRpcInPredictionLoop; + private bool m_WarnedNetworkVariableInPredictionLoop; + + /// + /// Whether this hybrid instance's ghost is currently inside the Netcode for Entities prediction loop. + /// + private bool IsInGhostPredictionLoop() + { + if (m_NetworkObject == null || !m_NetworkObject.HasGhost) + { + return false; + } + var ghostObject = m_NetworkObject.GhostObject; + return ghostObject != null && ghostObject.IsSpawned && ghostObject.NetworkTime.IsInPredictionLoop; + } + + // RPCs and NetworkVariables are not rolled back or tick aligned, so a re-simulated tick sends or writes them again. + // Warned once per NetworkBehaviour. + private void WarnIfRpcSentInPredictionLoop() + { + if (m_WarnedRpcInPredictionLoop || !IsInGhostPredictionLoop()) + { + return; + } + m_WarnedRpcInPredictionLoop = true; + if (NetworkLog.CurrentLogLevel <= LogLevel.Normal) + { + NetworkLog.LogWarning($"[{name}][{GetType().Name}] An RPC was sent from inside the Netcode for Entities prediction loop, which is not supported. " + + "RPCs are not part of prediction and are sent again for every re-simulated tick."); + } + } + + internal void WarnIfNetworkVariableWrittenInPredictionLoop(string variableName) + { + if (m_WarnedNetworkVariableInPredictionLoop || !IsInGhostPredictionLoop()) + { + return; + } + m_WarnedNetworkVariableInPredictionLoop = true; + if (NetworkLog.CurrentLogLevel <= LogLevel.Normal) + { + NetworkLog.LogWarning($"[{name}][{GetType().Name}][{variableName}] A NetworkVariable was written from inside the Netcode for Entities prediction loop, which is not supported. " + + "NetworkVariables are not rolled back, so re-simulated ticks write it again and its value can move backwards."); + } + } +#endif + #pragma warning disable IDE1006 // disable naming rule violation check // RuntimeAccessModifiersILPP will make this `protected` internal FastBufferWriter __beginSendServerRpc(uint rpcMethodId, ServerRpcParams serverRpcParams, RpcDelivery rpcDelivery) @@ -83,6 +131,9 @@ internal FastBufferWriter __beginSendServerRpc(uint rpcMethodId, ServerRpcParams { throw new RpcException("The NetworkBehaviour must be spawned before calling this method."); } +#if UNIFIED_NETCODE + WarnIfRpcSentInPredictionLoop(); +#endif return new FastBufferWriter(k_RpcMessageDefaultSize, Allocator.Temp, k_RpcMessageMaximumSize); } @@ -161,6 +212,9 @@ internal FastBufferWriter __beginSendClientRpc(uint rpcMethodId, ClientRpcParams { throw new RpcException("The NetworkBehaviour must be spawned before calling this method."); } +#if UNIFIED_NETCODE + WarnIfRpcSentInPredictionLoop(); +#endif return new FastBufferWriter(k_RpcMessageDefaultSize, Allocator.Temp, k_RpcMessageMaximumSize); } @@ -339,6 +393,9 @@ internal FastBufferWriter __beginSendRpc(uint rpcMethodId, RpcParams rpcParams, { throw new RpcException("This RPC can only be sent by its owner."); } +#if UNIFIED_NETCODE + WarnIfRpcSentInPredictionLoop(); +#endif return new FastBufferWriter(k_RpcMessageDefaultSize, Allocator.Temp, k_RpcMessageMaximumSize); } diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 185d682f66..2ce40447f8 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1834,8 +1834,11 @@ internal void ShutdownInternal() { try { - // Dispose of all worlds - World.DisposeAllWorlds(); + // Dispose only this NetworkManager's world. Other NetworkManagers in the same process keep theirs. + if (NetcodeWorld != null && NetcodeWorld.IsCreated) + { + NetcodeWorld.Dispose(); + } // Clear the world assigned from previous session NetcodeWorld = null; } diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs index 0238e0ddb7..c2ddf7d10e 100644 --- a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs @@ -197,6 +197,13 @@ internal static void CreateObject(ref NetworkManager networkManager, ulong sende networkManager.Log.ErrorServer(new Context(LogLevel.Developer, $"Failed to deserialize {nameof(NetworkObject)}.").AddInfo(nameof(NetworkObject.GlobalObjectIdHash), serializedObject.Hash).AddInfo(nameof(NetworkObject.NetworkObjectId), serializedObject.NetworkObjectId)); return; } + + // A NetworkObject shown to this client after it migrated into another scene was instantiated in the + // active scene, and the migration was only sent to the clients observing it at the time. + if (networkManager.NetworkConfig.EnableSceneManagement) + { + networkManager.SceneManager.SynchronizeNetworkObjectScene(networkObject); + } } else { diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs index dd52c55782..3e128fa508 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs @@ -281,6 +281,12 @@ public virtual void SetDirty(bool isDirty) if (m_IsDirty) { +#if UNIFIED_NETCODE + if (m_NetworkBehaviour != null) + { + m_NetworkBehaviour.WarnIfNetworkVariableWrittenInPredictionLoop(Name); + } +#endif MarkNetworkBehaviourDirty(); } } diff --git a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs index cafce0f7c1..0bb34043f1 100644 --- a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs @@ -2253,27 +2253,39 @@ private void SynchronizeNetworkObjectScene() { foreach (var networkObject in NetworkManager.SpawnManager.SpawnedObjectsList) { - // This is only done for dynamically spawned NetworkObjects - // Theoretically, a server could have NetworkObjects in a server-side only scene, if the client doesn't have that scene loaded - // then skip it (it will reside in the currently active scene in this scenario on the client-side) - if (!networkObject.InScenePlaced && ServerSceneHandleToClientSceneHandle.ContainsKey(networkObject.NetworkSceneHandle)) - { - networkObject.SceneOriginHandle = ServerSceneHandleToClientSceneHandle[networkObject.NetworkSceneHandle]; + SynchronizeNetworkObjectScene(networkObject); + } + } - // If the NetworkObject does not have a parent and is not in the same scene as it is on the server side, then find the right scene - // and move it to that scene. - if (networkObject.gameObject.scene.handle != networkObject.SceneOriginHandle && networkObject.transform.parent == null) + /// + /// Migrates a single client-side dynamically spawned NetworkObject into the scene it is in on the server-side. + /// + /// + /// Also used for hybrid prefab instances that are part of the initial synchronization but only spawn once + /// their ghost arrives, which can be after the synchronization has completed. + /// + internal void SynchronizeNetworkObjectScene(NetworkObject networkObject) + { + // This is only done for dynamically spawned NetworkObjects + // Theoretically, a server could have NetworkObjects in a server-side only scene, if the client doesn't have that scene loaded + // then skip it (it will reside in the currently active scene in this scenario on the client-side) + if (!networkObject.InScenePlaced && ServerSceneHandleToClientSceneHandle.ContainsKey(networkObject.NetworkSceneHandle)) + { + networkObject.SceneOriginHandle = ServerSceneHandleToClientSceneHandle[networkObject.NetworkSceneHandle]; + + // If the NetworkObject does not have a parent and is not in the same scene as it is on the server side, then find the right scene + // and move it to that scene. + if (networkObject.gameObject.scene.handle != networkObject.SceneOriginHandle && networkObject.transform.parent == null) + { + if (ScenesLoaded.ContainsKey(networkObject.SceneOriginHandle)) { - if (ScenesLoaded.ContainsKey(networkObject.SceneOriginHandle)) - { - var scene = ScenesLoaded[networkObject.SceneOriginHandle]; - SceneManager.MoveGameObjectToScene(networkObject.gameObject, scene); - } - else if (NetworkManager.LogLevel <= LogLevel.Normal) - { - NetworkLog.LogWarningServer($"[Client-{NetworkManager.LocalClientId}][{networkObject.gameObject.name}] Server - " + - $"client scene mismatch detected! Client-side has no scene loaded with handle ({networkObject.SceneOriginHandle})!"); - } + var scene = ScenesLoaded[networkObject.SceneOriginHandle]; + SceneManager.MoveGameObjectToScene(networkObject.gameObject, scene); + } + else if (NetworkManager.LogLevel <= LogLevel.Normal) + { + NetworkLog.LogWarningServer($"[Client-{NetworkManager.LocalClientId}][{networkObject.gameObject.name}] Server - " + + $"client scene mismatch detected! Client-side has no scene loaded with handle ({networkObject.SceneOriginHandle})!"); } } } @@ -2998,6 +3010,26 @@ internal void MigrateNetworkObjectsIntoScenes() private List m_ScenesToRemoveFromObjectMigration = new(); + private List m_SceneMigrationTargetIds = new(); + + private bool ObservesMigratedNetworkObject(ulong clientId) + { + foreach (var sceneEntry in ObjectsMigratedIntoNewScene) + { + if (!sceneEntry.Value.TryGetValue(NetworkManager.LocalClientId, out var migratedObjects)) + { + continue; + } + foreach (var networkObject in migratedObjects) + { + if (networkObject.Observers.Contains(clientId)) + { + return true; + } + } + } + return false; + } /// /// Should be invoked during PostLateUpdate just prior to the NetworkMessageManager processes its outbound message queue. @@ -3062,13 +3094,30 @@ internal void CheckForAndSendNetworkObjectSceneChanged() return; } + // Only send to the clients that observe at least one of the migrated NetworkObjects + m_SceneMigrationTargetIds.Clear(); + foreach (var clientId in NetworkManager.ConnectedClientsIds) + { + if (clientId != NetworkManager.LocalClientId && (NetworkManager.DistributedAuthorityMode || ObservesMigratedNetworkObject(clientId))) + { + m_SceneMigrationTargetIds.Add(clientId); + } + } + + // Distributed authority still sends to the CMB service when there are no other clients + if (m_SceneMigrationTargetIds.Count == 0 && !NetworkManager.DistributedAuthorityMode) + { + ObjectsMigratedIntoNewScene.Clear(); + return; + } + // Some NetworkObjects still exist, send the message var sceneEvent = BeginSceneEvent(); sceneEvent.SceneEventType = SceneEventType.ObjectSceneChanged; // SendSceneEventData can throw an exception. We need to wrap this and recover from the exception gracefully. try { - SendSceneEventData(sceneEvent.SceneEventId, NetworkManager.ConnectedClientsIds.Where(c => c != NetworkManager.LocalClientId).ToArray()); + SendSceneEventData(sceneEvent.SceneEventId, m_SceneMigrationTargetIds.ToArray()); } catch (Exception ex) { diff --git a/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs b/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs index 83974d882d..e956bf7a30 100644 --- a/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs +++ b/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs @@ -1218,6 +1218,15 @@ private void ReadSceneEventProgressDone(FastBufferReader reader) internal bool IsForwarding; private ulong m_OwnerId; + /// + /// Distributed authority forwards scene migrations through the session owner or the DAHost, so only + /// client-server filters them by the target client's observers. + /// + private bool IsMigrationSentToTarget(NetworkObject networkObject) + { + return m_NetworkManager.DistributedAuthorityMode || networkObject.Observers.Contains(TargetClientId); + } + private void SerializeObjectsMovedIntoNewScene(FastBufferWriter writer) { var sceneManager = m_NetworkManager.SceneManager; @@ -1241,17 +1250,35 @@ private void SerializeObjectsMovedIntoNewScene(FastBufferWriter writer) { // Since these are separated by scene then owner, there could be scenes that have // no changes. - if (!sceneHandleObjects.Value.ContainsKey(networkManagerClientId)) + if (!sceneHandleObjects.Value.TryGetValue(networkManagerClientId, out var migratedObjects)) + { + continue; + } + + // A client is only told about the objects it observes, since it has not spawned the others. + var objectCount = 0; + foreach (var networkObject in migratedObjects) + { + if (IsMigrationSentToTarget(networkObject)) + { + objectCount++; + } + } + if (objectCount == 0) { continue; } + // Write the scene handle writer.WriteValueSafe(sceneHandleObjects.Key); // Write the number of NetworkObjectIds to expect - writer.WriteValueSafe(sceneHandleObjects.Value[networkManagerClientId].Count); - foreach (var networkObject in sceneHandleObjects.Value[networkManagerClientId]) + writer.WriteValueSafe(objectCount); + foreach (var networkObject in migratedObjects) { - writer.WriteValueSafe(networkObject.NetworkObjectId); + if (IsMigrationSentToTarget(networkObject)) + { + writer.WriteValueSafe(networkObject.NetworkObjectId); + } } entriesWritten++; } diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs index ed6b512494..82a926bead 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs @@ -146,6 +146,12 @@ internal NetworkObject ProcessGhostPendingSynchronization(ulong networkObjectId, } var networkObject = NetworkObject.DeserializeAndSpawnObject(serializedObject, reader, m_NetworkManager); + // The synchronization moved the other NetworkObjects into their server-side scenes before this one spawned + if (networkObject != null) + { + m_NetworkManager.SceneManager.SynchronizeNetworkObjectScene(networkObject); + } + // TODO-UNIFIED: How do we handle the "all in-scene placed objects are spawned notification"? //if (serializedObject.IsSceneObject) //{ diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 28803104f4..9b93daa06f 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -591,6 +591,12 @@ internal void ChangeOwnership(NetworkObject networkObject, ulong clientId, bool // Assign the new owner networkObject.OwnerClientId = clientId; +#if UNIFIED_NETCODE + if (networkObject.HasGhost) + { + networkObject.NetworkObjectBridge.UpdateGhostOwner(NetworkManager, clientId); + } +#endif // Notify lost ownership, update the ownership, then notify gained ownership for the network behaviours networkObject.InvokeBehaviourOnOwnershipChanged(originalOwner, clientId); @@ -1302,6 +1308,13 @@ internal bool SpawnNetworkObjectLocallyCommon(NetworkObject networkObject, ulong } networkObject.SetupOnSpawn(networkId, playerObject, ownerClientId, destroyWithScene); +#if UNIFIED_NETCODE + // Before OnNetworkSpawn, so owner-specific initialization sees the ghost owner too. + if (networkObject.HasGhost) + { + networkObject.NetworkObjectBridge.UpdateGhostOwner(NetworkManager, ownerClientId); + } +#endif SpawnedObjects.Add(networkObject.NetworkObjectId, networkObject); SpawnedObjectsList.Add(networkObject); diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs index de23142229..e19b5167b4 100644 --- a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs +++ b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs @@ -221,7 +221,11 @@ private class ConnectionInfo internal void DispatchMessage(int connectionId, in FixedBytes1280 buffer) { - var connectionInfo = m_Connections[connectionId]; + // The connection is removed on disconnect, which can be ahead of its last messages. + if (!m_Connections.TryGetValue(connectionId, out ConnectionInfo connectionInfo)) + { + return; + } using var arr = FixedBytes1280.ToNativeArray(buffer); var reader = new DataStreamReader(arr); @@ -374,6 +378,8 @@ private void OnClientDisconnectFromServer(Connection connection, NetcodeConnecti GetDisconnectEventFromNetworkStreamDisconnectReason(connectionEvent.DisconnectReason), GetDisconnectMessageFromNetworkStreamDisconnectReason(connectionEvent.DisconnectReason) ); + // Removed before notifying, so the shutdown this triggers does not notify again from DisconnectLocalClient. + m_Connections.Remove(connectionEvent.Id.Value); InvokeOnTransportEvent(NetworkEvent.Disconnect, (ulong)connectionEvent.Id.Value, default, m_RealTimeProvider.RealTimeSinceStartup); } @@ -440,7 +446,7 @@ public override void DisconnectRemoteClient(ulong clientId) public override void DisconnectLocalClient() { // Remove the connection 1st (the world might not be available) - m_Connections.Remove((int)ServerClientId); + var wasConnected = m_Connections.Remove((int)ServerClientId); // TODO-FIX-REVIEW-ME: // This was causing errors to occur upon shutdown during an integration test. @@ -462,6 +468,13 @@ public override void DisconnectLocalClient() } m_NetworkManager.NetcodeWorld.RequestDisconnectFromServer(); + // N4E reports the disconnect a frame or more later, after NGO's shutdown has stopped listening, so the + // client would never be notified. Notify now, as UnityTransport does, and ignore N4E's later event. + m_NetworkManager.NetcodeWorld.OnConnectionEvent -= OnClientConnectionEvent; + if (wasConnected) + { + InvokeOnTransportEvent(NetworkEvent.Disconnect, ServerClientId, default, m_RealTimeProvider.RealTimeSinceStartup); + } } public override ulong GetCurrentRtt(ulong clientId) @@ -479,7 +492,12 @@ public override void Initialize(NetworkManager networkManager = null) public override void Shutdown() { - + var netcodeWorld = m_NetworkManager != null ? m_NetworkManager.NetcodeWorld : null; + if (netcodeWorld != null) + { + netcodeWorld.OnConnectionEvent -= OnClientConnectionEvent; + netcodeWorld.OnConnectionEvent -= OnServerConnectionEvent; + } } } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs index 82669d7187..172c11b41f 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs @@ -1,8 +1,8 @@ using System.Collections; using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; using UnityEngine.TestTools; namespace Unity.Netcode.RuntimeTests @@ -16,11 +16,11 @@ namespace Unity.Netcode.RuntimeTests /// - When a server disconnects a client that: /// -- The client detects this disconnection. /// -- The server cleans up the transport to NGO client (and vice versa) mappings. - /// - When the server-side player object is destroyed - /// - When the server-side player object ownership is transferred back to the server /// - [TestFixture(OwnerPersistence.DestroyWithOwner)] - [TestFixture(OwnerPersistence.DontDestroyWithOwner)] + [TestFixture(HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedHost)] +#endif internal class DisconnectTests : NetcodeIntegrationTest { public enum OwnerPersistence @@ -37,25 +37,20 @@ public enum ClientDisconnectType protected override int NumberOfClients => 2; - private OwnerPersistence m_OwnerPersistence; - private ClientDisconnectType m_ClientDisconnectType; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif private bool m_ClientDisconnected; private Dictionary m_DisconnectedEvent = new Dictionary(); - private ulong m_DisconnectEventClientId; private ulong m_TransportClientId; private ulong m_ClientId; + private GameObject m_TestPrefab; - public DisconnectTests(OwnerPersistence ownerPersistence) : base(HostOrServer.Host) - { - m_OwnerPersistence = ownerPersistence; - } - - protected override void OnCreatePlayerPrefab() - { - m_PlayerPrefab.GetComponent().DontDestroyWithOwner = m_OwnerPersistence == OwnerPersistence.DontDestroyWithOwner; - base.OnCreatePlayerPrefab(); - } + public DisconnectTests(HostOrServer hostOrServer) : base(hostOrServer) { } protected override void OnServerAndClientsCreated() { @@ -73,6 +68,7 @@ protected override void OnServerAndClientsCreated() { unityTransport.HeartbeatTimeoutMS = heartBeatTimeout; } + m_TestPrefab = CreateNetworkObjectPrefab("TestObject"); base.OnServerAndClientsCreated(); } @@ -134,22 +130,6 @@ private bool TransportIdCleanedUp() return transportId != m_TransportClientId; } - /// - /// Conditional check to make sure the client player object no longer exists on the server side - /// - private bool DoesServerStillHaveSpawnedPlayerObject() - { - if (m_PlayerNetworkObjects[m_ServerNetworkManager.LocalClientId].ContainsKey(m_ClientId)) - { - var playerObject = m_PlayerNetworkObjects[m_ServerNetworkManager.LocalClientId][m_ClientId]; - if (playerObject != null && playerObject.IsSpawned) - { - return false; - } - } - return !m_ServerNetworkManager.SpawnManager.SpawnedObjects.Any(x => x.Value.IsPlayerObject && x.Value.OwnerClientId == m_ClientId); - } - /// /// Used to compare against when the client-side disconnects /// @@ -158,29 +138,55 @@ private bool DoesServerStillHaveSpawnedPlayerObject() [UnityTest] public IEnumerator ClientPlayerDisconnected([Values] ClientDisconnectType clientDisconnectType) { - // Cycling through 2 (or more) clients disconnecting - for (int i = m_ClientNetworkManagers.Length - 1; i >= 0; i--) + // Validates that having an object spawned does not throw an exception when a client is disconnected. + var instance = SpawnObject(m_TestPrefab, GetAuthorityNetworkManager()); + + yield return WaitForSpawnedOnAllOrTimeOut(instance); + AssertOnTimeout($"Failed to spawn {instance.name}!"); + + for (int i = 0; i < m_ClientNetworkManagers.Length; i++) { - var client = m_ClientNetworkManagers[i]; - if (client.LocalClientId == m_ServerNetworkManager.LocalClientId) - { - continue; - } m_ExpectedConnectedClientCount = m_ServerNetworkManager.ConnectedClients.Count; yield return DisconnectClient(m_ClientNetworkManagers[i], clientDisconnectType); } + + // Validate the host-client generates a OnClientDisconnected event when it shuts down. + // Only test when the clients disconnected from the server (the server-side disconnect path is already validated above) + if (clientDisconnectType == ClientDisconnectType.ClientDisconnectsFromServer) + { + m_DisconnectedEvent.Clear(); + m_ClientDisconnected = false; + m_ServerNetworkManager.Shutdown(); + + yield return WaitForConditionOrTimeOut(() => m_ClientDisconnected); + AssertOnTimeout("Timed out waiting for host-client to generate disconnect message!"); + + Assert.IsTrue(m_DisconnectedEvent.ContainsKey(m_ServerNetworkManager), $"Could not find the server {nameof(NetworkManager)} disconnect event entry!"); + Assert.IsTrue(m_DisconnectedEvent[m_ServerNetworkManager].ClientId == NetworkManager.ServerClientId, $"Expected ClientID {NetworkManager.ServerClientId} but found ClientID {m_DisconnectedEvent[m_ServerNetworkManager].ClientId} for the server {nameof(NetworkManager)} disconnect event entry!"); + yield return s_DefaultWaitForTick; + if (m_ServerNetworkManager.ConnectionManager != null) + { + Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.IsClient, $"{m_ServerNetworkManager.name} still has IsClient setting!"); + Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.IsConnected, $"{m_ServerNetworkManager.name} still has IsConnected setting!"); + Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.ClientId != 0, $"{m_ServerNetworkManager.name} still has ClientId ({m_ServerNetworkManager.ConnectionManager.LocalClient.ClientId}) setting!"); + Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.IsApproved, $"{m_ServerNetworkManager.name} still has IsApproved setting!"); + Assert.IsNull(m_ServerNetworkManager.ConnectionManager.LocalClient.PlayerObject, $"{m_ServerNetworkManager.name} still has Player assigned!"); + } + } + m_DisconnectedEvent.Clear(); + m_ClientDisconnected = false; } private IEnumerator DisconnectClient(NetworkManager clientNetworkManager, ClientDisconnectType clientDisconnectType) { m_ClientId = clientNetworkManager.LocalClientId; - m_ClientDisconnectType = clientDisconnectType; + var context = $"[{clientDisconnectType}][Client-{m_ClientId}]"; var serverSideClientPlayer = m_ServerNetworkManager.ConnectionManager.ConnectedClients[m_ClientId].PlayerObject; bool connectionExists; (m_TransportClientId, connectionExists) = m_ServerNetworkManager.ConnectionManager.ClientIdToTransportId(m_ClientId); - Assert.IsTrue(connectionExists); + Assert.IsTrue(connectionExists, $"{context} No transport connection found for the client!"); if (clientDisconnectType == ClientDisconnectType.ServerDisconnectsClient) { @@ -188,9 +194,9 @@ private IEnumerator DisconnectClient(NetworkManager clientNetworkManager, Client clientNetworkManager.OnConnectionEvent += OnConnectionEvent; m_ServerNetworkManager.OnConnectionEvent += OnConnectionEvent; m_ServerNetworkManager.DisconnectClient(m_ClientId); - Assert.True(!string.IsNullOrEmpty(m_ServerNetworkManager.DisconnectReason), "Server-side disconnect notification should have been generated but was not!"); + Assert.True(!string.IsNullOrEmpty(m_ServerNetworkManager.DisconnectReason), $"{context} Server-side disconnect notification should have been generated but was not!"); var splitByDisconnectEvent = m_ServerNetworkManager.DisconnectReason.Split("[Disconnect Event]"); - Assert.IsTrue(splitByDisconnectEvent.Length <= 2, $"Multiple disconnect events found in the server-side disconnect reason:\n {m_ServerNetworkManager.DisconnectReason}"); + Assert.IsTrue(splitByDisconnectEvent.Length <= 2, $"{context} Multiple disconnect events found in the server-side disconnect reason:\n {m_ServerNetworkManager.DisconnectReason}"); } else { @@ -202,76 +208,35 @@ private IEnumerator DisconnectClient(NetworkManager clientNetworkManager, Client } yield return WaitForConditionOrTimeOut(() => m_ClientDisconnected); - AssertOnTimeout("Timed out waiting for client to disconnect!"); + AssertOnTimeout($"{context} Timed out waiting for client to disconnect!"); + Assert.IsTrue(m_DisconnectedEvent.ContainsKey(m_ServerNetworkManager), $"{context} Could not find the server {nameof(NetworkManager)} disconnect event entry!"); + Assert.IsTrue(m_DisconnectedEvent[m_ServerNetworkManager].ClientId == m_ClientId, $"{context} Expected ClientID {m_ClientId} but found ClientID {m_DisconnectedEvent[m_ServerNetworkManager].ClientId} for the server {nameof(NetworkManager)} disconnect event entry!"); + Assert.IsTrue(m_DisconnectedEvent.ContainsKey(clientNetworkManager), $"{context} Could not find the client {nameof(NetworkManager)} disconnect event entry!"); + Assert.IsTrue(m_DisconnectedEvent[clientNetworkManager].ClientId == m_ClientId, $"{context} Expected ClientID {m_ClientId} but found ClientID {m_DisconnectedEvent[clientNetworkManager].ClientId} for the client {nameof(NetworkManager)} disconnect event entry!"); if (clientDisconnectType == ClientDisconnectType.ServerDisconnectsClient) { - Assert.IsTrue(m_DisconnectedEvent.ContainsKey(m_ServerNetworkManager), $"Could not find the server {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_DisconnectedEvent[m_ServerNetworkManager].ClientId == m_ClientId, $"Expected ClientID {m_ClientId} but found ClientID {m_DisconnectedEvent[m_ServerNetworkManager].ClientId} for the server {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_DisconnectedEvent.ContainsKey(clientNetworkManager), $"Could not find the client {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_DisconnectedEvent[clientNetworkManager].ClientId == m_ClientId, $"Expected ClientID {m_ClientId} but found ClientID {m_DisconnectedEvent[m_ServerNetworkManager].ClientId} for the client {nameof(NetworkManager)} disconnect event entry!"); // Unregister for this event otherwise it will be invoked during teardown m_ServerNetworkManager.OnConnectionEvent -= OnConnectionEvent; } else { m_ExpectedConnectedClientCount -= 1; - Assert.IsTrue(m_DisconnectedEvent.ContainsKey(m_ServerNetworkManager), $"Could not find the server {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_DisconnectedEvent[m_ServerNetworkManager].ClientId == m_ClientId, $"Expected ClientID {m_ClientId} but found ClientID {m_DisconnectedEvent[m_ServerNetworkManager].ClientId} for the server {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_DisconnectedEvent.ContainsKey(clientNetworkManager), $"Could not find the client {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_DisconnectedEvent[clientNetworkManager].ClientId == m_ClientId, $"Expected ClientID {m_ClientId} but found ClientID {m_DisconnectedEvent[m_ServerNetworkManager].ClientId} for the client {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_ServerNetworkManager.ConnectedClientsIds.Count == m_ExpectedConnectedClientCount, $"Expected connected client identifiers count to be {m_ExpectedConnectedClientCount} but it was {m_ServerNetworkManager.ConnectedClientsIds.Count}!"); - Assert.IsTrue(m_ServerNetworkManager.ConnectedClients.Count == m_ExpectedConnectedClientCount, $"Expected connected client identifiers count to be {m_ExpectedConnectedClientCount} but it was {m_ServerNetworkManager.ConnectedClients.Count}!"); - Assert.IsTrue(m_ServerNetworkManager.ConnectedClientsList.Count == m_ExpectedConnectedClientCount, $"Expected connected client identifiers count to be {m_ExpectedConnectedClientCount} but it was {m_ServerNetworkManager.ConnectedClientsList.Count}!"); - } - - if (m_OwnerPersistence == OwnerPersistence.DestroyWithOwner) - { - // When we are destroying with the owner, validate the player object is destroyed on the server side - yield return WaitForConditionOrTimeOut(DoesServerStillHaveSpawnedPlayerObject); - AssertOnTimeout("Timed out waiting for client's player object to be destroyed!"); - } - else - { - // When we are not destroying with the owner, ensure the player object's ownership was transferred back to the server - yield return WaitForConditionOrTimeOut(() => serverSideClientPlayer.IsOwnedByServer); - AssertOnTimeout("The client's player object's ownership was not transferred back to the server!"); + Assert.IsTrue(m_ServerNetworkManager.ConnectedClientsIds.Count == m_ExpectedConnectedClientCount, $"{context} Expected connected client identifiers count to be {m_ExpectedConnectedClientCount} but it was {m_ServerNetworkManager.ConnectedClientsIds.Count}!"); + Assert.IsTrue(m_ServerNetworkManager.ConnectedClients.Count == m_ExpectedConnectedClientCount, $"{context} Expected connected clients count to be {m_ExpectedConnectedClientCount} but it was {m_ServerNetworkManager.ConnectedClients.Count}!"); + Assert.IsTrue(m_ServerNetworkManager.ConnectedClientsList.Count == m_ExpectedConnectedClientCount, $"{context} Expected connected clients list count to be {m_ExpectedConnectedClientCount} but it was {m_ServerNetworkManager.ConnectedClientsList.Count}!"); } yield return WaitForConditionOrTimeOut(TransportIdCleanedUp); - AssertOnTimeout("Timed out waiting for transport and client id mappings to be cleaned up!"); - + AssertOnTimeout($"{context} Timed out waiting for transport and client id mappings to be cleaned up!"); if (clientNetworkManager.ConnectionManager != null) { - Assert.False(clientNetworkManager.ConnectionManager.LocalClient.IsClient, $"{clientNetworkManager.name} still has IsClient setting!"); - Assert.False(clientNetworkManager.ConnectionManager.LocalClient.IsConnected, $"{clientNetworkManager.name} still has IsConnected setting!"); - Assert.False(clientNetworkManager.ConnectionManager.LocalClient.ClientId != 0, $"{clientNetworkManager.name} still has ClientId ({clientNetworkManager.ConnectionManager.LocalClient.ClientId}) setting!"); - Assert.False(clientNetworkManager.ConnectionManager.LocalClient.IsApproved, $"{clientNetworkManager.name} still has IsApproved setting!"); - Assert.IsNull(clientNetworkManager.ConnectionManager.LocalClient.PlayerObject, $"{clientNetworkManager.name} still has Player assigned!"); - } - // Validate the host-client generates a OnClientDisconnected event when it shutsdown. - // Only test when the test run is the client disconnecting from the server (otherwise the server will be shutdown already) - if (clientDisconnectType == ClientDisconnectType.ClientDisconnectsFromServer) - { - m_DisconnectedEvent.Clear(); - m_ClientDisconnected = false; - m_ServerNetworkManager.Shutdown(); - - yield return WaitForConditionOrTimeOut(() => m_ClientDisconnected); - AssertOnTimeout("Timed out waiting for host-client to generate disconnect message!"); - - Assert.IsTrue(m_DisconnectedEvent.ContainsKey(m_ServerNetworkManager), $"Could not find the server {nameof(NetworkManager)} disconnect event entry!"); - Assert.IsTrue(m_DisconnectedEvent[m_ServerNetworkManager].ClientId == NetworkManager.ServerClientId, $"Expected ClientID {m_ClientId} but found ClientID {m_DisconnectedEvent[m_ServerNetworkManager].ClientId} for the server {nameof(NetworkManager)} disconnect event entry!"); - yield return s_DefaultWaitForTick; - if (m_ServerNetworkManager.ConnectionManager != null) - { - Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.IsClient, $"{m_ServerNetworkManager.name} still has IsClient setting!"); - Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.IsConnected, $"{m_ServerNetworkManager.name} still has IsConnected setting!"); - Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.ClientId != 0, $"{m_ServerNetworkManager.name} still has ClientId ({clientNetworkManager.ConnectionManager.LocalClient.ClientId}) setting!"); - Assert.False(m_ServerNetworkManager.ConnectionManager.LocalClient.IsApproved, $"{m_ServerNetworkManager.name} still has IsApproved setting!"); - Assert.IsNull(m_ServerNetworkManager.ConnectionManager.LocalClient.PlayerObject, $"{m_ServerNetworkManager.name} still has Player assigned!"); - } + Assert.False(clientNetworkManager.ConnectionManager.LocalClient.IsClient, $"{context} {clientNetworkManager.name} still has IsClient setting!"); + Assert.False(clientNetworkManager.ConnectionManager.LocalClient.IsConnected, $"{context} {clientNetworkManager.name} still has IsConnected setting!"); + Assert.False(clientNetworkManager.ConnectionManager.LocalClient.ClientId != 0, $"{context} {clientNetworkManager.name} still has ClientId ({clientNetworkManager.ConnectionManager.LocalClient.ClientId}) setting!"); + Assert.False(clientNetworkManager.ConnectionManager.LocalClient.IsApproved, $"{context} {clientNetworkManager.name} still has IsApproved setting!"); + Assert.IsNull(clientNetworkManager.ConnectionManager.LocalClient.PlayerObject, $"{context} {clientNetworkManager.name} still has Player assigned!"); } m_DisconnectedEvent.Clear(); m_ClientDisconnected = false; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs index f243918d2d..a298640bc1 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs @@ -13,11 +13,22 @@ namespace Unity.Netcode.RuntimeTests [TestFixture(HostOrServer.DAHost)] [TestFixture(HostOrServer.Host)] [TestFixture(HostOrServer.Server)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] +#endif internal class NetworkObjectDontDestroyWithOwnerTests : NetcodeIntegrationTest { private const int k_NumberObjectsToSpawn = 16; protected override int NumberOfClients => 3; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + public enum ParentedPass { NoParent, diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs new file mode 100644 index 0000000000..55ceca9f9a --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs @@ -0,0 +1,152 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that a client is only told about the scene migrations of the s it observes, + /// and that a shown after it migrated spawns in the authority's scene. + /// + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.Server)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] +#endif + internal class NetworkObjectSceneMigrationObserverTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 2; + + private GameObject m_PrefabNoObserversSpawn; + private GameObject m_PrefabWithObservers; + +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + + public NetworkObjectSceneMigrationObserverTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override void OnServerAndClientsCreated() + { + m_PrefabNoObserversSpawn = CreateNetworkObjectPrefab("NoObserversObject"); + m_PrefabNoObserversSpawn.GetComponent().SpawnWithObservers = false; + m_PrefabWithObservers = CreateNetworkObjectPrefab("WithObserversObject"); + base.OnServerAndClientsCreated(); + } + + private NetworkObject SpawnAndShow(NetworkManager authority, NetworkManager observer) + { + var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent(); + networkObject.NetworkShow(observer.LocalClientId); + return networkObject; + } + + private IEnumerator WaitForSpawnedOn(NetworkManager networkManager, NetworkObject networkObject) + { + yield return WaitForConditionOrTimeOut(() => networkManager.SpawnManager.SpawnedObjects.ContainsKey(networkObject.NetworkObjectId)); + AssertOnTimeout($"[Client-{networkManager.LocalClientId}] Failed to spawn {networkObject.name} when it was shown!"); + } + + /// + /// Two objects each shown to a different client migrate into different scenes in the same frame. Each client + /// must only receive the migration of the object it observes. + /// + [UnityTest] + public IEnumerator SceneMigrationIsOnlySentToObservers() + { + var authority = GetAuthorityNetworkManager(); + var firstClient = GetNonAuthorityNetworkManager(0); + var secondClient = GetNonAuthorityNetworkManager(1); + var activeScene = SceneManager.GetActiveScene(); + + var firstObject = SpawnAndShow(authority, firstClient); + var secondObject = SpawnAndShow(authority, secondClient); + // Start the first object in the DontDestroyOnLoad scene so the two objects migrate into different scenes + Object.DontDestroyOnLoad(firstObject.gameObject); + yield return WaitForSpawnedOn(firstClient, firstObject); + yield return WaitForSpawnedOn(secondClient, secondObject); + yield return s_DefaultWaitForTick; + + SceneManager.MoveGameObjectToScene(firstObject.gameObject, activeScene); + Object.DontDestroyOnLoad(secondObject.gameObject); + + var firstClientInstance = firstClient.SpawnManager.SpawnedObjects[firstObject.NetworkObjectId]; + var secondClientInstance = secondClient.SpawnManager.SpawnedObjects[secondObject.NetworkObjectId]; + yield return WaitForConditionOrTimeOut(() => firstClientInstance.gameObject.scene == activeScene + && secondClientInstance.gameObject.scene == secondClient.SceneManager.DontDestroyOnLoadScene); + AssertOnTimeout($"The observing clients did not migrate their instances! " + + $"[Client-{firstClient.LocalClientId}] {firstClientInstance.gameObject.scene.name}, [Client-{secondClient.LocalClientId}] {secondClientInstance.gameObject.scene.name}"); + + Assert.False(firstClient.SpawnManager.SpawnedObjects.ContainsKey(secondObject.NetworkObjectId), $"[Client-{firstClient.LocalClientId}] Spawned {secondObject.name} without observing it!"); + Assert.False(secondClient.SpawnManager.SpawnedObjects.ContainsKey(firstObject.NetworkObjectId), $"[Client-{secondClient.LocalClientId}] Spawned {firstObject.name} without observing it!"); + LogAssert.NoUnexpectedReceived(); + } + + /// + /// An object that migrates while no client observes it sends no migration. + /// + [UnityTest] + public IEnumerator SceneMigrationWithNoObserversIsNotSent() + { + var authority = GetAuthorityNetworkManager(); + + var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent(); + yield return s_DefaultWaitForTick; + Object.DontDestroyOnLoad(networkObject.gameObject); + + // Wait long enough for a migration to have been sent and processed + yield return new WaitForSeconds(0.25f); + LogAssert.NoUnexpectedReceived(); + } + + /// + /// An object shown to a client after it migrated while hidden from that client spawns in the authority's scene. + /// + [UnityTest] + public IEnumerator NetworkShowAfterSceneMigrationSpawnsInAuthorityScene() + { + var authority = GetAuthorityNetworkManager(); + var client = GetNonAuthorityNetworkManager(); + + var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent(); + yield return s_DefaultWaitForTick; + Object.DontDestroyOnLoad(networkObject.gameObject); + yield return s_DefaultWaitForTick; + + networkObject.NetworkShow(client.LocalClientId); + yield return WaitForSpawnedOn(client, networkObject); + + var clientInstance = client.SpawnManager.SpawnedObjects[networkObject.NetworkObjectId]; + Assert.AreEqual(client.SceneManager.DontDestroyOnLoadScene, clientInstance.gameObject.scene, $"[Client-{client.LocalClientId}] {networkObject.name} did not spawn in the authority's scene!"); + } + + /// + /// A late joining client synchronizes an object that is not in the active scene into the authority's scene. + /// A hybrid prefab instance can spawn after the synchronization completes, once its ghost arrives. + /// + [UnityTest] + public IEnumerator LateJoinSynchronizesObjectIntoAuthorityScene() + { + var authority = GetAuthorityNetworkManager(); + var networkObject = SpawnObject(m_PrefabWithObservers, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(networkObject); + AssertOnTimeout($"Failed to spawn {networkObject.name} on all clients!"); + Object.DontDestroyOnLoad(networkObject.gameObject); + yield return s_DefaultWaitForTick; + + yield return CreateAndStartNewClient(); + var lateJoinClient = m_ClientNetworkManagers[m_ClientNetworkManagers.Length - 1]; + yield return WaitForSpawnedOn(lateJoinClient, networkObject); + + var clientInstance = lateJoinClient.SpawnManager.SpawnedObjects[networkObject.NetworkObjectId]; + Assert.AreEqual(lateJoinClient.SceneManager.DontDestroyOnLoadScene, clientInstance.gameObject.scene, $"[Client-{lateJoinClient.LocalClientId}] {networkObject.name} did not spawn in the authority's scene!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta new file mode 100644 index 0000000000..94364e42d7 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4a1fe891864e4ae7867dd6f8dda3492c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs index 710c1b0662..8ee89c82e1 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs @@ -8,10 +8,20 @@ namespace Unity.Netcode.RuntimeTests { [TestFixture(HostOrServer.DAHost)] [TestFixture(HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedHost)] +#endif internal class NetworkSpawnManagerTests : NetcodeIntegrationTest { protected override int NumberOfClients => 2; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + public NetworkSpawnManagerTests(HostOrServer hostOrServer) : base(hostOrServer) { } [Test] diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs index ef6d71fad5..8cee662843 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs @@ -17,14 +17,15 @@ namespace Unity.Netcode.RuntimeTests /// - When a server disconnects a client that: /// -- The client detects this disconnection. /// -- The server cleans up the transport to NGO client (and vice versa) mappings. - /// - When the server-side player object is destroyed - /// - When the server-side player object ownership is transferred back to the server /// [TestFixture(HostOrServer.Server)] [TestFixture(HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedServer)] + [TestFixture(HostOrServer.UnifiedHost)] +#endif internal class PeerDisconnectCallbackTests : NetcodeIntegrationTest { - public enum ClientDisconnectType { ServerDisconnectsClient, @@ -33,6 +34,13 @@ public enum ClientDisconnectType protected override int NumberOfClients => 3; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + private int m_ClientDisconnectCount; private int m_PeerDisconnectCount; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 3d24452348..578b179a5a 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -860,7 +860,13 @@ private void RegisterPendingGhost(NetworkObject networkObject, ulong networkObje // If the world matches, then register the instance with this NetworkManager's spawn manager. if (networkManager.NetcodeWorld == ghost.World) { - networkManager.SpawnManager.GhostSpawnManager.RegisterGhostPendingSpawn(networkObject, networkObjectId); + // Like GhostSpawnManager.RegisterGhostBridge, only clients wait for a ghost. Registering the server's + // own instance moved it into the DontDestroyOnLoad scene on a dedicated server, which every client + // was then told about. + if (!networkManager.IsServer) + { + networkManager.SpawnManager.GhostSpawnManager.RegisterGhostPendingSpawn(networkObject, networkObjectId); + } return; } } @@ -2554,7 +2560,7 @@ protected void CleanupPrefabReferences() } } private bool m_HybridPrefabCreated; - protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true) + private GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true, GhostMode ghostMode = GhostMode.Interpolated) { m_HybridPrefabCreated = true; // Prevent from trying to register/spawn when creating this hybrid prefab @@ -2579,13 +2585,16 @@ protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true) // Initialize it as a prefab adapter.InitializeAsPrefab(); - // TODO: This might be part of the CreateHybridPrefab parameters - // For now, just use normal interpolation until we get integration - // tests running. - // Once we have validated prediction works and have a working manual - // test, we can circle back to this (possibly make that a sub-task - // with the dependency to prediction manual test). - adapter.SupportedGhostModes = GhostModeMask.Interpolated; + if (ghostMode == GhostMode.Interpolated) + { + adapter.SupportedGhostModes = GhostModeMask.Interpolated; + } + else + { + adapter.SupportedGhostModes = GhostModeMask.All; + adapter.DefaultGhostMode = ghostMode; + adapter.HasOwner = ghostMode == GhostMode.OwnerPredicted; + } // Once done with setting up the GhostObject, we can set it back to active in the hierarchy gameObject.SetActive(true); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta new file mode 100644 index 0000000000..f6b8f32a91 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a3b63559c6f99f34cb5dac72e9c4d695 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs new file mode 100644 index 0000000000..6886f6142a --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs @@ -0,0 +1,285 @@ +#if UNIFIED_NETCODE +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// The N4E half of the interop prefab: relays pings between unified remotes and NGO RPCs, and sends an NGO RPC or + /// writes a NetworkVariable from on request. + /// + internal partial class HybridInteropGhost : GhostBehaviour + { + public bool SendRpcFromPrediction; + public bool WriteNetworkVariableFromPrediction; + public int PredictionSends; + public int PredictionWrites; + public int PingValue; + + public override void PredictionUpdate(PredictionUpdateContext context) + { + if (IsServer) + { + return; + } + var networkBehaviour = GetComponent(); + if (SendRpcFromPrediction) + { + PredictionSends++; + networkBehaviour.PredictionRpc(); + } + if (WriteNetworkVariableFromPrediction) + { + PredictionWrites++; + networkBehaviour.OwnerWrittenValue.Value++; + } + } + + [RPC(SendDirection.ServerToClient)] + public void PingToClient(int value) + { + PingValue = value; + GetComponent().PingToServerRpc(value + 1); + } + + [RPC(SendDirection.ServerToClient)] + public void FinalPingToClient(int value) + { + PingValue = value; + } + + [RPC(SendDirection.ClientToServer)] + public void PingToServer(int value) + { + PingValue = value; + GetComponent().FinalPingToClientRpc(value + 1); + } + } + + /// + /// The NGO half of the interop prefab. + /// + internal class HybridInteropNetworkBehaviour : NetworkBehaviour + { + public int PingValue; + public int PredictionRpcsReceived; + public NetworkVariable OwnerWrittenValue = new NetworkVariable(writePerm: NetworkVariableWritePermission.Owner); + public bool GhostWasPredictedOnSpawn; + public NetworkId GhostOwnerOnSpawn; + + public override void OnNetworkSpawn() + { + var ghost = GetComponent(); + GhostWasPredictedOnSpawn = ghost.CanWriteState; + GhostOwnerOnSpawn = ghost.OwnerNetworkId; + } + + [Rpc(SendTo.Server)] + public void PredictionRpc() + { + PredictionRpcsReceived++; + } + + [Rpc(SendTo.Server)] + public void PingToServerRpc(int value) + { + PingValue = value; + GetComponent().FinalPingToClient(value + 1); + } + + [Rpc(SendTo.NotServer)] + public void PingToClientRpc(int value) + { + PingValue = value; + GetComponent().PingToServer(value + 1); + } + + [Rpc(SendTo.NotServer)] + public void FinalPingToClientRpc(int value) + { + PingValue = value; + } + } + + /// + /// Combines N4E remotes and prediction with NGO RPCs on the same hybrid prefab. + /// + /// + /// One client only: a ClientToServer remote is sent from every client world in the process. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridInteropTests : NetcodeIntegrationTest + { + // Enough prediction ticks that a second warning would have been logged if it was not limited to one per NetworkBehaviour + private const int k_MinimumPredictionCalls = 20; + private const string k_PredictionLoopWarning = "Netcode for Entities prediction loop, which is not supported"; + + protected override int NumberOfClients => 1; + + private GameObject m_InteropPrefab; + private NetworkObject m_ServerInstance; + private NetworkObject m_ClientInstance; + private int m_PredictionLoopWarnings; + + public HybridInteropTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_InteropPrefab = CreateHybridPrefab("InteropPrefab", true, GhostMode.OwnerPredicted); + m_InteropPrefab.AddComponent(); + m_InteropPrefab.AddComponent(); + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnServerAndClientsConnected() + { + var client = m_ClientNetworkManagers[0]; + m_ServerInstance = SpawnObject(m_InteropPrefab, client).GetComponent(); + var clientNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; + Assert.AreEqual(clientNetworkId, m_ServerInstance.GetComponent().OwnerNetworkId, "Spawning with an NGO owner did not set the ghost owner!"); + Assert.AreEqual(clientNetworkId, m_ServerInstance.GetComponent().GhostOwnerOnSpawn, + "The ghost owner was not set yet when OnNetworkSpawn was invoked!"); + yield return WaitForSpawnedOnAllOrTimeOut(m_ServerInstance); + AssertOnTimeout($"Timed out waiting for {m_ServerInstance.name} to spawn on all clients!"); + m_ClientInstance = client.SpawnManager.SpawnedObjects[m_ServerInstance.NetworkObjectId]; + yield return WaitForConditionOrTimeOut(() => m_ClientInstance.GetComponent().CanWriteState); + AssertOnTimeout($"{m_ClientInstance.name} never became predicted on the client!"); + + m_PredictionLoopWarnings = 0; + Application.logMessageReceived += OnLogMessageReceived; + } + + protected override IEnumerator OnTearDown() + { + Application.logMessageReceived -= OnLogMessageReceived; + return base.OnTearDown(); + } + + private void OnLogMessageReceived(string condition, string stackTrace, LogType type) + { + if (type == LogType.Warning && condition.Contains(k_PredictionLoopWarning)) + { + m_PredictionLoopWarnings++; + } + } + + /// + /// Unified remote to NGO RPC to unified remote, starting on the server. + /// + [UnityTest] + public IEnumerator UnifiedRemoteToNgoRpcToUnifiedRemote() + { + var clientGhost = m_ClientInstance.GetComponent(); + m_ServerInstance.GetComponent().PingToClient(1); + yield return WaitForConditionOrTimeOut(() => clientGhost.PingValue == 3); + AssertOnTimeout($"Ping did not complete! Client remote value: {clientGhost.PingValue}, " + + $"server RPC value: {m_ServerInstance.GetComponent().PingValue}"); + } + + /// + /// NGO RPC to unified remote to NGO RPC, starting on the server. + /// + [UnityTest] + public IEnumerator NgoRpcToUnifiedRemoteToNgoRpc() + { + var clientBehaviour = m_ClientInstance.GetComponent(); + m_ServerInstance.GetComponent().PingToClientRpc(1); + yield return WaitForConditionOrTimeOut(() => clientBehaviour.PingValue == 3); + AssertOnTimeout($"Ping did not complete! Client RPC value: {clientBehaviour.PingValue}, " + + $"server remote value: {m_ServerInstance.GetComponent().PingValue}"); + } + + /// + /// Sending an NGO RPC from is not supported and logs one warning per NetworkBehaviour. + /// + [UnityTest] + public IEnumerator RpcSentFromPredictionUpdateLogsWarningOnce() + { + var clientGhost = m_ClientInstance.GetComponent(); + clientGhost.SendRpcFromPrediction = true; + yield return WaitForConditionOrTimeOut(() => clientGhost.PredictionSends >= k_MinimumPredictionCalls); + clientGhost.SendRpcFromPrediction = false; + AssertOnTimeout($"Client prediction only sent {clientGhost.PredictionSends} RPCs!"); + + // Wait for the RPCs to reach the server, so none is still queued at teardown. + var serverBehaviour = m_ServerInstance.GetComponent(); + yield return WaitForConditionOrTimeOut(() => serverBehaviour.PredictionRpcsReceived == clientGhost.PredictionSends); + AssertOnTimeout($"Server received {serverBehaviour.PredictionRpcsReceived} of {clientGhost.PredictionSends} RPCs!"); + + Assert.AreEqual(1, m_PredictionLoopWarnings, $"Expected one prediction loop warning for {clientGhost.PredictionSends} RPCs sent from prediction."); + } + + /// + /// Writing a NetworkVariable from is not supported and logs one warning per NetworkBehaviour. + /// + [UnityTest] + public IEnumerator NetworkVariableWrittenFromPredictionUpdateLogsWarningOnce() + { + var clientGhost = m_ClientInstance.GetComponent(); + var clientBehaviour = m_ClientInstance.GetComponent(); + clientGhost.WriteNetworkVariableFromPrediction = true; + yield return WaitForConditionOrTimeOut(() => clientGhost.PredictionWrites >= k_MinimumPredictionCalls); + clientGhost.WriteNetworkVariableFromPrediction = false; + AssertOnTimeout($"Client prediction only wrote the NetworkVariable {clientGhost.PredictionWrites} times!"); + + // Wait for the last write to reach the server, so no NetworkVariable update is still queued at teardown. + var serverBehaviour = m_ServerInstance.GetComponent(); + yield return WaitForConditionOrTimeOut(() => serverBehaviour.OwnerWrittenValue.Value == clientBehaviour.OwnerWrittenValue.Value); + AssertOnTimeout($"Server value {serverBehaviour.OwnerWrittenValue.Value} never matched the client value {clientBehaviour.OwnerWrittenValue.Value}!"); + + Assert.AreEqual(1, m_PredictionLoopWarnings, $"Expected one prediction loop warning for {clientGhost.PredictionWrites} NetworkVariable writes from prediction."); + } + + /// + /// The same RPC and NetworkVariable write made outside the prediction loop do not warn. + /// + [UnityTest] + public IEnumerator RpcAndNetworkVariableOutsidePredictionLoopDoNotWarn() + { + var clientBehaviour = m_ClientInstance.GetComponent(); + var serverBehaviour = m_ServerInstance.GetComponent(); + clientBehaviour.PredictionRpc(); + clientBehaviour.OwnerWrittenValue.Value++; + yield return WaitForConditionOrTimeOut(() => serverBehaviour.PredictionRpcsReceived == 1 && serverBehaviour.OwnerWrittenValue.Value == clientBehaviour.OwnerWrittenValue.Value); + AssertOnTimeout($"Server received {serverBehaviour.PredictionRpcsReceived} RPCs and value {serverBehaviour.OwnerWrittenValue.Value} (client value {clientBehaviour.OwnerWrittenValue.Value})!"); + + Assert.AreEqual(0, m_PredictionLoopWarnings, "An RPC or NetworkVariable write made outside the prediction loop logged a prediction loop warning."); + } + + /// + /// An NGO ownership change also changes the ghost's owner, so the new NGO owner is the one that predicts. + /// + [UnityTest] + public IEnumerator NgoOwnershipChangeUpdatesGhostOwner() + { + var client = m_ClientNetworkManagers[0]; + var serverGhost = m_ServerInstance.GetComponent(); + var clientGhost = m_ClientInstance.GetComponent(); + + // The host's own client owns the ghost when the host takes ownership. A server without a local client leaves it unowned. + var serverOwnerNetworkId = m_ServerNetworkManager.IsHost ? m_ServerNetworkManager.NetcodeWorld.LocalConnection.NetworkId : default; + m_ServerInstance.ChangeOwnership(m_ServerNetworkManager.LocalClientId); + Assert.AreEqual(serverOwnerNetworkId, serverGhost.OwnerNetworkId, "The ghost owner did not follow the NGO owner to the server!"); + yield return WaitForConditionOrTimeOut(() => m_ClientInstance.OwnerClientId == m_ServerNetworkManager.LocalClientId && clientGhost.OwnerNetworkId.Equals(serverOwnerNetworkId)); + AssertOnTimeout($"Client never saw the ghost owner change! NGO owner: {m_ClientInstance.OwnerClientId}, ghost owner: {clientGhost.OwnerNetworkId.Value}"); + Debug.Log($"Client ghost predicted after losing ownership: {clientGhost.CanWriteState}. Predicted on spawn: " + + $"{m_ClientInstance.GetComponent().GhostWasPredictedOnSpawn}"); + + var clientNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; + m_ServerInstance.ChangeOwnership(client.LocalClientId); + Assert.AreEqual(clientNetworkId, serverGhost.OwnerNetworkId, "The ghost owner did not follow the NGO owner back to the client!"); + yield return WaitForConditionOrTimeOut(() => clientGhost.OwnerNetworkId.Equals(clientNetworkId) && clientGhost.CanWriteState); + AssertOnTimeout($"The client did not predict the ghost after regaining ownership! Ghost owner: {clientGhost.OwnerNetworkId.Value}, predicted: {clientGhost.CanWriteState}"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs.meta new file mode 100644 index 0000000000..1f17a05678 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c71281769fd67e46b437cb430ce262d \ No newline at end of file diff --git a/testproject/Assets/Tests/Runtime/NetworkSceneManager/NetworkObjectSceneMigrationTests.cs b/testproject/Assets/Tests/Runtime/NetworkSceneManager/NetworkObjectSceneMigrationTests.cs index 736de24acf..1b74c94af4 100644 --- a/testproject/Assets/Tests/Runtime/NetworkSceneManager/NetworkObjectSceneMigrationTests.cs +++ b/testproject/Assets/Tests/Runtime/NetworkSceneManager/NetworkObjectSceneMigrationTests.cs @@ -38,7 +38,7 @@ public NetworkObjectSceneMigrationTests(HostOrServer hostOrServer) : base(hostOr // TODO: [MTT-15430] Fix automatic scene object migration between clients protected override bool UseCMBService() { - return false; + return true; } protected override IEnumerator OnSetup()