diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 7b75f45d30..621606f479 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -18,6 +18,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..609e42a8d8 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs @@ -25,6 +25,18 @@ internal partial class NetworkObjectBridge : GhostBehaviour // 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 +47,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 +96,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/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/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..f1259f8b7e 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs @@ -19,8 +19,12 @@ namespace Unity.Netcode.RuntimeTests /// - 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(OwnerPersistence.DestroyWithOwner, HostOrServer.Host)] + [TestFixture(OwnerPersistence.DontDestroyWithOwner, HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(OwnerPersistence.DestroyWithOwner, HostOrServer.UnifiedHost)] + [TestFixture(OwnerPersistence.DontDestroyWithOwner, HostOrServer.UnifiedHost)] +#endif internal class DisconnectTests : NetcodeIntegrationTest { public enum OwnerPersistence @@ -37,6 +41,13 @@ public enum ClientDisconnectType protected override int NumberOfClients => 2; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + private OwnerPersistence m_OwnerPersistence; private ClientDisconnectType m_ClientDisconnectType; private bool m_ClientDisconnected; @@ -46,7 +57,7 @@ public enum ClientDisconnectType private ulong m_ClientId; - public DisconnectTests(OwnerPersistence ownerPersistence) : base(HostOrServer.Host) + public DisconnectTests(OwnerPersistence ownerPersistence, HostOrServer hostOrServer) : base(hostOrServer) { m_OwnerPersistence = ownerPersistence; } 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..b9895bc733 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs @@ -22,6 +22,10 @@ namespace Unity.Netcode.RuntimeTests /// [TestFixture(HostOrServer.Server)] [TestFixture(HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedServer)] + [TestFixture(HostOrServer.UnifiedHost)] +#endif internal class PeerDisconnectCallbackTests : NetcodeIntegrationTest { @@ -33,6 +37,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..4af4ddd960 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) + protected 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..1af4cfa97d --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs @@ -0,0 +1,449 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// A NetworkVariable value stamped with the tick it applies from, so prediction can apply it tick-aligned. + /// + internal struct TickStampedValue : INetworkSerializable, IEquatable + { + public int Value; + public int PreviousValue; + public uint Tick; + + public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter + { + serializer.SerializeValue(ref Value); + serializer.SerializeValue(ref PreviousValue); + serializer.SerializeValue(ref Tick); + } + + public bool Equals(TickStampedValue other) + { + return Value == other.Value && PreviousValue == other.PreviousValue && Tick == other.Tick; + } + } + + /// + /// The N4E half of the interop prefab: sends NGO RPCs from and + /// relays pings between unified remotes and NGO RPCs. + /// + internal partial class HybridInteropGhost : GhostBehaviour + { + public bool SendRpcFromPrediction; + public bool GateOnFirstTimeTick; + public int PredictionSends; + public int ResimulatedTicks; + public int PingValue; + + public bool WriteNetworkVariableFromPrediction; + public bool RecordStampedValue; + public uint LatestPredictedTick; + public int EarlyReadsOfNewValue; + public int InconsistentRawTicks; + public int InconsistentStampedTicks; + public int StampedValueAtStampTick; + private readonly Dictionary m_RawValueByTick = new Dictionary(); + private readonly Dictionary m_StampedValueByTick = new Dictionary(); + + public override void PredictionUpdate(float tickedDeltaTime) + { + if (IsServer) + { + return; + } + var networkTime = Ghost.World.NetworkTime; + if (RecordStampedValue) + { + RecordStampedValueAtTick(networkTime.ServerTick.TickIndexForValidTick); + } + if (WriteNetworkVariableFromPrediction && (!GateOnFirstTimeTick || networkTime.IsFirstTimeFullyPredictingTick)) + { + GetComponent().OwnerWrittenTick.Value = networkTime.ServerTick.TickIndexForValidTick; + } + if (!SendRpcFromPrediction) + { + return; + } + if (!networkTime.IsFirstTimeFullyPredictingTick) + { + ResimulatedTicks++; + if (GateOnFirstTimeTick) + { + return; + } + } + PredictionSends++; + GetComponent().PredictionTickRpc(networkTime.ServerTick.SerializedData); + } + + /// + /// Records, per predicted tick, the raw NetworkVariable value and the value the tick stamp says applies to that tick. + /// + private void RecordStampedValueAtTick(uint tick) + { + var stamped = GetComponent().StampedValue.Value; + var hasStamp = stamped.Tick != 0; + if (hasStamp && tick < stamped.Tick && stamped.Value == HybridInteropNetworkBehaviour.StampedNewValue) + { + EarlyReadsOfNewValue++; + } + // The pattern under test: apply the value only when the tick being predicted is at or past its stamp. + var applied = hasStamp && tick >= stamped.Tick ? stamped.Value : stamped.PreviousValue; + if (hasStamp && tick == stamped.Tick) + { + StampedValueAtStampTick = applied; + } + InconsistentRawTicks += RecordValue(m_RawValueByTick, tick, stamped.Value); + InconsistentStampedTicks += RecordValue(m_StampedValueByTick, tick, applied); + if (tick > LatestPredictedTick) + { + LatestPredictedTick = tick; + } + } + + /// 1 if this tick was already predicted with a different value, otherwise 0. + private static int RecordValue(Dictionary valueByTick, uint tick, int value) + { + if (valueByTick.TryGetValue(tick, out var previous)) + { + return previous == value ? 0 : 1; + } + valueByTick.Add(tick, value); + return 0; + } + + [Remote(Directionality.ServerToClient)] + public void PingToClient(int value) + { + PingValue = value; + GetComponent().PingToServerRpc(value + 1); + } + + [Remote(Directionality.ServerToClient)] + public void FinalPingToClient(int value) + { + PingValue = value; + } + + [Remote(Directionality.ClientToServer)] + public void PingToServer(int value) + { + PingValue = value; + GetComponent().FinalPingToClientRpc(value + 1); + } + } + + /// + /// The NGO half of the interop prefab. + /// + internal class HybridInteropNetworkBehaviour : NetworkBehaviour + { + public const int StampedNewValue = 1; + + public readonly List ReceivedPredictionTicks = new List(); + public int PingValue; + public NetworkVariable StampedValue = new NetworkVariable(); + public NetworkVariable OwnerWrittenTick = new NetworkVariable(writePerm: NetworkVariableWritePermission.Owner); + public int OwnerWrittenTickChanges; + public int OwnerWrittenTickDecreases; + public bool GhostWasPredictedOnSpawn; + public NetworkId GhostOwnerOnSpawn; + + public override void OnNetworkSpawn() + { + var ghost = GetComponent(); + GhostWasPredictedOnSpawn = ghost.IsPredictedGhost; + GhostOwnerOnSpawn = ghost.OwnerNetworkId; + OwnerWrittenTick.OnValueChanged += OnOwnerWrittenTickChanged; + } + + public override void OnNetworkDespawn() + { + OwnerWrittenTick.OnValueChanged -= OnOwnerWrittenTickChanged; + } + + private void OnOwnerWrittenTickChanged(uint previous, uint current) + { + OwnerWrittenTickChanges++; + if (current < previous) + { + OwnerWrittenTickDecreases++; + } + } + + [Rpc(SendTo.Server)] + public void PredictionTickRpc(uint tick) + { + ReceivedPredictionTicks.Add(tick); + } + + [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 + { + private const int k_MinimumPredictionSends = 20; + private const uint k_StampLeadTicks = 20; + private const uint k_TicksPastStamp = 5; + + protected override int NumberOfClients => 1; + + private GameObject m_InteropPrefab; + private NetworkObject m_ServerInstance; + private NetworkObject m_ClientInstance; + + 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().IsPredictedGhost); + AssertOnTimeout($"{m_ClientInstance.name} never became predicted on the client!"); + } + + /// + /// An NGO RPC sent from is sent again for every re-simulated tick. + /// + [UnityTest] + public IEnumerator RpcFromPredictionUpdateRepeatsForResimulatedTicks() + { + yield return SendRpcsFromPrediction(false); + var duplicates = CountDuplicateTicks(); + Assert.Greater(duplicates, 0, "Expected re-simulated ticks to send the same tick more than once."); + } + + /// + /// Gating the send on IsFirstTimeFullyPredictingTick sends each predicted tick once. + /// + [UnityTest] + public IEnumerator RpcFromPredictionUpdateGatedOnFirstTimeTickSendsEachTickOnce() + { + yield return SendRpcsFromPrediction(true); + var duplicates = CountDuplicateTicks(); + Assert.AreEqual(0, duplicates, $"{duplicates} ticks were sent more than once."); + } + + /// + /// 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}"); + } + + /// + /// A NetworkVariable is not rolled back: re-simulating a tick can read a different value than its first prediction did. + /// + [UnityTest] + public IEnumerator NetworkVariableReadDuringPredictionIsNotTickAligned() + { + yield return RecordAcrossStampedValueChange(); + var clientGhost = m_ClientInstance.GetComponent(); + Assert.Greater(clientGhost.InconsistentRawTicks, 0, "Expected a re-simulated tick to read a different value than its first prediction."); + } + + /// + /// Applying a NetworkVariable only from its stamped tick gives every re-simulation of a tick the same value. + /// + [UnityTest] + public IEnumerator TickStampedNetworkVariableIsConsistentAcrossResimulation() + { + yield return RecordAcrossStampedValueChange(); + var clientGhost = m_ClientInstance.GetComponent(); + Assert.AreEqual(0, clientGhost.InconsistentStampedTicks, $"{clientGhost.InconsistentStampedTicks} ticks applied a different stamped value on re-simulation."); + Assert.AreEqual(HybridInteropNetworkBehaviour.StampedNewValue, clientGhost.StampedValueAtStampTick, "The stamped value was not applied at its stamp tick."); + } + + /// + /// A NetworkVariable written from is written again when older ticks + /// re-simulate, so the owner's value moves backwards. + /// + [UnityTest] + public IEnumerator NetworkVariableWrittenFromPredictionUpdateMovesBackwards() + { + yield return WriteNetworkVariableFromPrediction(false); + var clientBehaviour = m_ClientInstance.GetComponent(); + Assert.Greater(clientBehaviour.OwnerWrittenTickDecreases, 0, "Expected re-simulated ticks to write an older tick."); + } + + /// + /// Gating the write on IsFirstTimeFullyPredictingTick only moves the value forward. + /// + [UnityTest] + public IEnumerator NetworkVariableWrittenFromPredictionUpdateGatedOnFirstTimeTickOnlyMovesForward() + { + yield return WriteNetworkVariableFromPrediction(true); + var clientBehaviour = m_ClientInstance.GetComponent(); + Assert.AreEqual(0, clientBehaviour.OwnerWrittenTickDecreases, $"The value moved backwards {clientBehaviour.OwnerWrittenTickDecreases} times."); + } + + /// + /// 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.IsPredictedGhost}. 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.IsPredictedGhost); + AssertOnTimeout($"The client did not predict the ghost after regaining ownership! Ghost owner: {clientGhost.OwnerNetworkId.Value}, predicted: {clientGhost.IsPredictedGhost}"); + } + + private IEnumerator WriteNetworkVariableFromPrediction(bool gateOnFirstTimeTick) + { + var clientGhost = m_ClientInstance.GetComponent(); + var clientBehaviour = m_ClientInstance.GetComponent(); + clientGhost.GateOnFirstTimeTick = gateOnFirstTimeTick; + clientGhost.WriteNetworkVariableFromPrediction = true; + // Ungated, keep writing until a re-simulation has happened, so a backwards move had a chance to occur. + yield return WaitForConditionOrTimeOut(() => clientBehaviour.OwnerWrittenTickChanges >= k_MinimumPredictionSends && (gateOnFirstTimeTick || clientBehaviour.OwnerWrittenTickDecreases > 0)); + clientGhost.WriteNetworkVariableFromPrediction = false; + AssertOnTimeout($"Only {clientBehaviour.OwnerWrittenTickChanges} value changes were written, with {clientBehaviour.OwnerWrittenTickDecreases} backwards moves!"); + + // 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.OwnerWrittenTick.Value == clientBehaviour.OwnerWrittenTick.Value); + AssertOnTimeout($"Server value {serverBehaviour.OwnerWrittenTick.Value} never matched the client value {clientBehaviour.OwnerWrittenTick.Value}!"); + Debug.Log($"Gated: {gateOnFirstTimeTick}, value changes: {clientBehaviour.OwnerWrittenTickChanges}, backwards moves: {clientBehaviour.OwnerWrittenTickDecreases}"); + } + + private IEnumerator RecordAcrossStampedValueChange() + { + var clientGhost = m_ClientInstance.GetComponent(); + clientGhost.RecordStampedValue = true; + + // Stamp far enough ahead that the value reaches the client before it predicts the stamp tick. + var serverTick = m_ServerInstance.GetComponent().World.NetworkTime.ServerTick.TickIndexForValidTick; + var stampTick = serverTick + k_StampLeadTicks; + m_ServerInstance.GetComponent().StampedValue.Value = new TickStampedValue + { + Value = HybridInteropNetworkBehaviour.StampedNewValue, + PreviousValue = 0, + Tick = stampTick, + }; + + yield return WaitForConditionOrTimeOut(() => clientGhost.LatestPredictedTick >= stampTick + k_TicksPastStamp); + clientGhost.RecordStampedValue = false; + AssertOnTimeout($"Client never predicted past tick {stampTick + k_TicksPastStamp}! Latest predicted tick: {clientGhost.LatestPredictedTick}"); + Debug.Log($"Stamp tick: {stampTick}, early reads of the new value: {clientGhost.EarlyReadsOfNewValue}, " + + $"inconsistent raw ticks: {clientGhost.InconsistentRawTicks}, inconsistent stamped ticks: {clientGhost.InconsistentStampedTicks}"); + } + + private IEnumerator SendRpcsFromPrediction(bool gateOnFirstTimeTick) + { + var clientGhost = m_ClientInstance.GetComponent(); + clientGhost.GateOnFirstTimeTick = gateOnFirstTimeTick; + clientGhost.SendRpcFromPrediction = true; + yield return WaitForConditionOrTimeOut(() => clientGhost.PredictionSends >= k_MinimumPredictionSends && clientGhost.ResimulatedTicks > 0); + clientGhost.SendRpcFromPrediction = false; + AssertOnTimeout($"Client prediction did not send enough RPCs! Sends: {clientGhost.PredictionSends}, re-simulated ticks: {clientGhost.ResimulatedTicks}"); + + var serverBehaviour = m_ServerInstance.GetComponent(); + yield return WaitForConditionOrTimeOut(() => serverBehaviour.ReceivedPredictionTicks.Count == clientGhost.PredictionSends); + AssertOnTimeout($"Server received {serverBehaviour.ReceivedPredictionTicks.Count} of {clientGhost.PredictionSends} RPCs!"); + Debug.Log($"Gated: {gateOnFirstTimeTick}, sends: {clientGhost.PredictionSends}, re-simulated ticks: {clientGhost.ResimulatedTicks}, duplicate ticks: {CountDuplicateTicks()}"); + } + + private int CountDuplicateTicks() + { + var seen = new HashSet(); + var duplicates = 0; + foreach (var tick in m_ServerInstance.GetComponent().ReceivedPredictionTicks) + { + if (!seen.Add(tick)) + { + duplicates++; + } + } + return duplicates; + } + } +} +#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/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs new file mode 100644 index 0000000000..17af9fd822 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs @@ -0,0 +1,79 @@ +#if UNIFIED_NETCODE +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Counts calls, split into first-time ticks and re-simulated ticks. + /// + internal partial class HybridPredictionProbe : GhostBehaviour + { + public GhostField PredictedCounter; + public int FirstTimeTicks; + public int ResimulatedTicks; + + public override void PredictionUpdate(float tickedDeltaTime) + { + if (Ghost.World.NetworkTime.IsFirstTimeFullyPredictingTick) + { + FirstTimeTicks++; + } + else + { + ResimulatedTicks++; + } + PredictedCounter.Value = PredictedCounter.Value + 1; + } + } + + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridPredictionTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + private GameObject m_PredictedPrefab; + + public HybridPredictionTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_PredictedPrefab = CreateHybridPrefab("PredictedProbe", true, GhostMode.OwnerPredicted); + m_PredictedPrefab.AddComponent(); + base.OnServerAndClientsCreated(); + } + + /// + /// An owner-predicted hybrid prefab is predicted on the owning client, and the client re-simulates + /// ticks when snapshots arrive even with no added latency. + /// + [UnityTest] + public IEnumerator OwnerPredictedHybridPrefabResimulatesOnClient() + { + var client = m_ClientNetworkManagers[0]; + var serverInstance = SpawnObject(m_PredictedPrefab, client).GetComponent(); + + yield return WaitForSpawnedOnAllOrTimeOut(serverInstance); + AssertOnTimeout($"Timed out waiting for {serverInstance.name} to spawn on all clients!"); + + var clientProbe = client.SpawnManager.SpawnedObjects[serverInstance.NetworkObjectId].GetComponent(); + yield return WaitForConditionOrTimeOut(() => clientProbe.Ghost.IsPredictedGhost && clientProbe.ResimulatedTicks > 0); + AssertOnTimeout($"Client never re-simulated {serverInstance.name}! Predicted: {clientProbe.Ghost.IsPredictedGhost}, " + + $"first-time ticks: {clientProbe.FirstTimeTicks}, re-simulated ticks: {clientProbe.ResimulatedTicks}"); + + var serverProbe = serverInstance.GetComponent(); + Debug.Log($"[{m_ServerNetworkManager.name}] first-time: {serverProbe.FirstTimeTicks}, re-simulated: {serverProbe.ResimulatedTicks} | " + + $"[{client.name}] first-time: {clientProbe.FirstTimeTicks}, re-simulated: {clientProbe.ResimulatedTicks}"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs.meta new file mode 100644 index 0000000000..750cd2414c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 980b79cfdf0c0a941ab1b1ad5eb79db6 \ No newline at end of file