diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 38d75261b8..eff10fb540 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,6 +10,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added +- Added Unity CLI commands (`netcode.gameobjects` groups) to set up NetworkManagers and network prefabs, start and stop sessions, inspect clients and spawned objects, spawn, despawn and change ownership, simulate network conditions and sample network traffic. They are available when the project has `com.unity.pipeline` 0.9.0-exp.1 or later. - Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session carrying `GhostObject` prefabs is started, so `GhostObject` updates land on the same interval as the rest of Netcode for GameObjects. (#4144) - Added `NetworkConfig.AutoConfigureSnapshotSize`, enabled by default, which sets the Netcode for Entities snapshot packet size to 4096 bytes when a session carrying `GhostObject` prefabs is started. Netcode for Entities defaults to a single MTU, which updates `GhostObject`s well below the tick rate at the instance counts Netcode for GameObjects projects typically run. Disable it to manage the value yourself. (#4144) diff --git a/com.unity.netcode.gameobjects/Documentation~/TableOfContents.md b/com.unity.netcode.gameobjects/Documentation~/TableOfContents.md index e351f13683..002b4e9528 100644 --- a/com.unity.netcode.gameobjects/Documentation~/TableOfContents.md +++ b/com.unity.netcode.gameobjects/Documentation~/TableOfContents.md @@ -102,6 +102,7 @@ * [Logging](basics/logging.md) * [Techniques and tricks for debugging multiplayer games](tutorials/testing/techniques_and_tricks_for_debugging_multiplayer_games.md) * [Command-line helper](tutorials/command-line-helper.md) + * [Unity CLI commands](unity-cli-commands.md) * [Troubleshooting](troubleshooting/troubleshooting.md) * [Error messages](troubleshooting/error-messages.md) * [FAQ](learn/faq.md) diff --git a/com.unity.netcode.gameobjects/Documentation~/testing-debugging.md b/com.unity.netcode.gameobjects/Documentation~/testing-debugging.md index e7075845d2..e782c9237d 100644 --- a/com.unity.netcode.gameobjects/Documentation~/testing-debugging.md +++ b/com.unity.netcode.gameobjects/Documentation~/testing-debugging.md @@ -10,5 +10,6 @@ Understand how to set up session management in Netcode for GameObjects. | **[Logging](basics/logging.md)** | In a multiplayer game, clients might get disconnected from the server for a variety of reasons (such as network issues or application/device crashes). For those reasons, you might want to allow your players to reconnect to the game. | | **[Techniques and tricks for debugging multiplayer games](tutorials/testing/techniques_and_tricks_for_debugging_multiplayer_games.md)** | In a multiplayer game, clients might get disconnected from the server for a variety of reasons (such as network issues or application/device crashes). For those reasons, you might want to allow your players to reconnect to the game. | | **[Command-line helper](tutorials/command-line-helper.md)** | In a multiplayer game, clients might get disconnected from the server for a variety of reasons (such as network issues or application/device crashes). For those reasons, you might want to allow your players to reconnect to the game. | +| **[Unity CLI commands](unity-cli-commands.md)** | Drive Netcode for GameObjects from the command line, CI, or an AI agent: set up NetworkManagers and prefabs, run sessions, inspect clients and objects, and simulate network conditions. | | **[Troubleshooting](troubleshooting/troubleshooting.md)** | In a multiplayer game, clients might get disconnected from the server for a variety of reasons (such as network issues or application/device crashes). For those reasons, you might want to allow your players to reconnect to the game. | | **[Error messages](troubleshooting/error-messages.md)** | In a multiplayer game, clients might get disconnected from the server for a variety of reasons (such as network issues or application/device crashes). For those reasons, you might want to allow your players to reconnect to the game. | \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Documentation~/unity-cli-commands.md b/com.unity.netcode.gameobjects/Documentation~/unity-cli-commands.md new file mode 100644 index 0000000000..c3b1cee79f --- /dev/null +++ b/com.unity.netcode.gameobjects/Documentation~/unity-cli-commands.md @@ -0,0 +1,75 @@ +# Unity CLI commands + +Drive Netcode for GameObjects from the command line, a CI job, or an AI agent with the [Unity CLI](https://docs.unity.com/en-us/unity-cli/unity-cli). You can use commands to set up a NetworkManager and network prefabs, start and stop sessions, inspect connected clients and spawned objects, simulate poor network conditions, and sample network traffic. + +To use Unity CLI commands with Netcode for GameObjects, you must install the [Unity Pipeline package](https://docs.unity.com/en-us/unity-cli/unity-pipeline/unity-pipeline-package) (version 0.9.0-exp.1 or later). Without it, the commands aren't compiled. Run them against an open Unity Editor as `unity command [arguments]`. `unity command --list` shows every command with its arguments. + +## Typical workflow + +``` +unity command netcode.gameobjects create_network_manager +unity command netcode.gameobjects make_prefab --prefab Assets/Prefabs/Player.prefab +unity command netcode.gameobjects.config set --field player_prefab --value Assets/Prefabs/Player.prefab +unity command editor.playmode start +unity command netcode.gameobjects.session start --mode Host +unity command netcode.gameobjects.session wait_ready +unity command netcode.gameobjects list_objects +``` + +To test with several players, use [Multiplayer Play Mode](https://docs.unity3d.com/Packages/com.unity.multiplayer.playmode@latest) scenarios (`editor.playmode start_scenario`) and run the commands against each player's Editor with `unity command --project-path `. + +## Commands + +### netcode.gameobjects + +| **Command** | **Description** | +| :--- | :--- | +| `status` | Each NetworkManager: role (host, server, client), connection state, local client id, topology, session owner, client and object counts, ticks, transport address, and whether the session runs hybrid prefabs. | +| `list_clients` | Connected clients with their player object, owned object count and round trip time. | +| `list_objects` | Spawned NetworkObjects as this peer sees them, filtered by name or owner. | +| `get_object` | One NetworkObject: owner, authority, ownership flags and who can change them, observers and NetworkBehaviours. | +| `spawn` | Instantiate and spawn a registered network prefab at a position, optionally for an owner or as a player object. | +| `despawn` | Despawn a NetworkObject, optionally keeping the GameObject. | +| `change_owner` | Give a NetworkObject to another client, following the topology's ownership rules. | +| `create_network_manager` | Add a NetworkManager with a UnityTransport to the active scene. | +| `list_prefabs` | Network prefabs lists with their prefabs, and the lists and player prefab each NetworkManager uses. | +| `register_prefab` | Add a network prefab to a network prefabs list. | +| `make_prefab` | Add a NetworkObject to a prefab's root and report its prefab id hash. | +| `audit` | Find setup problems that otherwise show when a session starts: in NetworkManagers (transport, topology, prefab lists, player prefab, hybrid prefab requirements) and in network prefabs (NetworkBehaviours without a NetworkObject, nested NetworkObjects, duplicate prefab ids, unregistered prefabs). `--scope config` or `--scope prefabs` runs one half. | + +### netcode.gameobjects.profiler + +| **Command** | **Description** | +| :--- | :--- | +| `get_stats` | Sample network traffic over a number of frames: bytes and messages sent and received, per message type. | + +### netcode.gameobjects.session + +| **Command** | **Description** | +| :--- | :--- | +| `start` | Start the NetworkManager as host, server or client, optionally with another address and port. | +| `stop` | Shut the NetworkManager down. | +| `kick` | Disconnect a client from the server or host. | +| `reconnect` | Disconnect a client and connect it again, then wait for it to be connected. A client that was already disconnected, for example kicked or timed out, just connects again. | +| `wait_ready` | Wait until a client is connected, or a server has a number of clients. | + +### netcode.gameobjects.simulator + +The simulator applies packet delay, jitter and loss to UnityTransport through its simulator pipeline stage, which is available in the Editor and development builds. Settings last for the Editor session and also apply to sessions started later. Once the simulator, `timeout` and `lag_spike` are all off, the transport gets its own simulator settings back. + +| **Command** | **Description** | +| :--- | :--- | +| `get` | The current simulated conditions. | +| `set` | Set a preset and/or packet delay, jitter and loss. | +| `list_presets` | Network condition presets. They come from the Multiplayer Tools package (2.0 or later). | +| `lag_spike` | Drop every packet for a short time. | +| `timeout` | Drop every packet until turned off, so connections time out. | + +### netcode.gameobjects.config + +| **Command** | **Description** | +| :--- | :--- | +| `get` | The NetworkManager's configuration and the names `set` accepts. | +| `set` | Set a NetworkConfig field (for example `tick_rate`), the transport `address`, `port` or `listen_address`, the `player_prefab`, or the `log_level`. The log level also changes a running session. | + +Commands that change scenes or assets are undoable where Unity supports it and don't save the scene; save it with `unity command scene save`. Commands that change a running session apply to the Editor they run in. diff --git a/com.unity.netcode.gameobjects/Editor/AssemblyInfo.cs b/com.unity.netcode.gameobjects/Editor/AssemblyInfo.cs index fce6c4572d..aa19002334 100644 --- a/com.unity.netcode.gameobjects/Editor/AssemblyInfo.cs +++ b/com.unity.netcode.gameobjects/Editor/AssemblyInfo.cs @@ -1,8 +1,12 @@ using System.Runtime.CompilerServices; +// The Unity CLI commands (com.unity.pipeline) wrap the internal query layer in Editor/CliQueries. +[assembly: InternalsVisibleTo("Unity.Netcode.GameObjects.CLI.Editor")] + #if UNITY_INCLUDE_TESTS #if UNITY_EDITOR [assembly: InternalsVisibleTo("Unity.Netcode.GameObjects.Editor.Tests")] +[assembly: InternalsVisibleTo("Unity.Netcode.GameObjects.CLI.Editor.Tests")] [assembly: InternalsVisibleTo("TestProject.Runtime.Tests")] #endif // UNITY_EDITOR #endif // UNITY_INCLUDE_TESTS diff --git a/com.unity.netcode.gameobjects/Editor/CLI.meta b/com.unity.netcode.gameobjects/Editor/CLI.meta new file mode 100644 index 0000000000..0303796d03 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CLI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c4c238c7b4d14fb45b8892f9e5e87293 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Editor/CLI/AssemblyInfo.cs b/com.unity.netcode.gameobjects/Editor/CLI/AssemblyInfo.cs new file mode 100644 index 0000000000..997da74910 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CLI/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +#if UNITY_INCLUDE_TESTS +[assembly: InternalsVisibleTo("Unity.Netcode.GameObjects.CLI.Editor.Tests")] +#endif // UNITY_INCLUDE_TESTS diff --git a/com.unity.netcode.gameobjects/Editor/CLI/AssemblyInfo.cs.meta b/com.unity.netcode.gameobjects/Editor/CLI/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..4b63d3bb63 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CLI/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 661edd6fed5a97e4e9a330aed238745a \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CLI/NetcodeGameObjectsCliCommands.cs b/com.unity.netcode.gameobjects/Editor/CLI/NetcodeGameObjectsCliCommands.cs new file mode 100644 index 0000000000..ef50ad8f59 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CLI/NetcodeGameObjectsCliCommands.cs @@ -0,0 +1,229 @@ +using System; +using System.Threading.Tasks; +using Unity.Pipeline.Commands; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + // Netcode for GameObjects commands for the Unity CLI (com.unity.pipeline), run as `unity command `. + // Each is a thin wrapper over NgoCliQueries, which holds the logic and its tests. The command name is the method + // name in snake_case, so renaming a method renames its command. Parameter order and Required are wire API too: + // add new parameters at the end, as optional. Commands that wait declare a double `timeout` parameter, which the + // pipeline fills with the time left of the request. + + /// NetworkManager status, clients, spawned objects, prefabs and traffic: group netcode.gameobjects. + internal static class NetcodeGameObjectsCliCommands + { + private const string k_Group = NgoCliQueries.Group; + internal const string Tag = "networking/netcode"; + + [CliGroupCommand(k_Group, "Each NetworkManager in the open scenes: role (host, server, client), connection state, local client id, topology, session owner, client and object counts, ticks, transport address and whether the session runs hybrid (Unified Netcode) prefabs. Outside play mode, the configuration only.", Tags = new[] { Tag })] + private static object Status() + => NgoCliOutput.ToWire(NgoCliQueries.GetStatus()); + + [CliGroupCommand(k_Group, "Connected clients of the running NetworkManager: client id, local, server, session owner, player object, owned object count and round trip time. Requires a running session.", Tags = new[] { Tag })] + private static object ListClients( + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.ListClients(manager)); + + [CliGroupCommand(k_Group, "Spawned NetworkObjects of the running NetworkManager, as this peer sees them: network id, name, prefab id hash, owner, authority, player object, in-scene placed, hybrid, position. Requires a running session.", Tags = new[] { Tag })] + private static object ListObjects( + [CliArg("name_contains", "Only objects whose name contains this text.")] string nameContains = null, + [CliArg("owner", "Only objects owned by this client id. -1 for all.")] long owner = -1, + [CliArg("limit", "Maximum objects returned.")] int limit = 100, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.ListObjects(manager, nameContains, owner, limit)); + + [CliGroupCommand(k_Group, "One spawned NetworkObject: owner, authority, ownership flags and who may change them, observers, NetworkBehaviours, scene and position. Requires a running session.", Tags = new[] { Tag })] + private static object GetObject( + [CliArg("id", "NetworkObjectId, from netcode.gameobjects list_objects.", Required = true)] long id, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.GetObject(id, manager)); + + [CliGroupCommand(k_Group, "Instantiate and spawn a registered network prefab. Server or host in client-server; any client in distributed authority (which then owns it). Returns the spawned object.", Tags = new[] { Tag })] + private static object Spawn( + [CliArg("prefab", "Prefab path, GUID or name.", Required = true)] string prefab, + [CliArg("position", "World position as [x, y, z]. Defaults to the prefab's.")] float[] position = null, + [CliArg("owner", "Owning client id (client-server only). -1 for the server.")] long owner = -1, + [CliArg("player_object", "Spawn it as the owner's player object.")] bool playerObject = false, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.Spawn(prefab, position, owner, playerObject, manager)); + + [CliGroupCommand(k_Group, "Despawn a NetworkObject on every peer. Server or host in client-server; the owner in distributed authority.", Tags = new[] { Tag })] + private static object Despawn( + [CliArg("id", "NetworkObjectId, from netcode.gameobjects list_objects.", Required = true)] long id, + [CliArg("destroy", "Destroy the GameObject too. False keeps it in the scene, unspawned.")] bool destroy = true, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.Despawn(id, destroy, manager)); + + [CliGroupCommand(k_Group, "Give a NetworkObject to another client. Server or host in client-server; in distributed authority the owner, subject to the object's ownership flags (see netcode.gameobjects get_object).", Tags = new[] { Tag })] + private static object ChangeOwner( + [CliArg("id", "NetworkObjectId, from netcode.gameobjects list_objects.", Required = true)] long id, + [CliArg("owner", "New owner's client id, from netcode.gameobjects list_clients.", Required = true)] long owner, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.ChangeOwner(id, owner, manager)); + + [CliGroupCommand(k_Group, "Add a NetworkManager with a UnityTransport to the active scene, with the default network prefabs list attached. Undoable; saves nothing. Not allowed in play mode.", Tags = new[] { Tag })] + private static object CreateNetworkManager( + [CliArg("name", "GameObject name.")] string name = "NetworkManager", + [CliArg("topology", "ClientServer or DistributedAuthority.")] NetworkTopologyTypes topology = NetworkTopologyTypes.ClientServer, + [CliArg("address", "Address clients connect to.")] string address = "127.0.0.1", + [CliArg("port", "Port the server listens on and clients connect to.")] int port = 7777, + [CliArg("dry_run", "Validate and report without changing the scene.")] bool dryRun = false) + => NgoCliOutput.ToWire(NgoCliQueries.CreateNetworkManager(name, topology, address, port, dryRun)); + + [CliGroupCommand(k_Group, "Network prefabs lists in the project with their prefabs (path, prefab id hash, overrides, hybrid), the lists and player prefab each NetworkManager uses, and whether the default list is generated automatically.", Tags = new[] { Tag })] + private static object ListPrefabs( + [CliArg("list", "Only lists whose path contains this text.")] string list = null) + => NgoCliOutput.ToWire(NgoCliQueries.ListPrefabs(list)); + + [CliGroupCommand(k_Group, "Add a network prefab to a network prefabs list (the given one, else the scene's NetworkManager's, else the default). Reports when it's already registered. Not allowed in play mode.", Tags = new[] { Tag })] + private static object RegisterPrefab( + [CliArg("prefab", "Prefab path, GUID or name.", Required = true)] string prefab, + [CliArg("list", "NetworkPrefabsList asset path.")] string list = null, + [CliArg("dry_run", "Validate and report without changing the list.")] bool dryRun = false) + => NgoCliOutput.ToWire(NgoCliQueries.RegisterPrefab(prefab, list, dryRun)); + + [CliGroupCommand(k_Group, "Make a prefab a network prefab: add a NetworkObject to its root and report its prefab id hash and list registration. Saves the prefab. Not allowed in play mode.", Tags = new[] { Tag })] + private static object MakePrefab( + [CliArg("prefab", "Prefab path, GUID or name.", Required = true)] string prefab, + [CliArg("dry_run", "Validate and report without changing the prefab.")] bool dryRun = false) + => NgoCliOutput.ToWire(NgoCliQueries.MakePrefab(prefab, dryRun)); + + [CliGroupCommand(k_Group, "Check the project for problems that otherwise show when a session starts: NetworkManager setups in the open scenes (transport, topology, prefab lists, player prefab, hierarchy, hybrid prefab requirements) and network prefabs (NetworkBehaviours without a NetworkObject, nested or misplaced NetworkObjects, NetworkManagers in network prefabs, missing or duplicate prefab ids, unregistered prefabs, broken list entries).", Tags = new[] { Tag })] + private static object Audit( + [CliArg("scope", "all, config (NetworkManagers) or prefabs.")] string scope = "all", + [CliArg("folder", "Prefabs only: prefabs under this folder, e.g. Assets/Prefabs.")] string folder = null, + [CliArg("limit", "Maximum issues returned (counts include all).")] int limit = 200, + [CliArg("manager", "Config only: this NetworkManager (name or hierarchy path). Omit for all.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.Audit(scope, folder, limit, manager)); + + /// How long a command may wait: stop a little before the request's timeout so the reply lands first. + internal static float WaitSeconds(double timeoutInSeconds) + { + if (double.IsPositiveInfinity(timeoutInSeconds) || timeoutInSeconds > 600) + { + return 600; + } + var margin = Math.Min(2.0, timeoutInSeconds / 10); + return (float)Math.Max(timeoutInSeconds - margin, 0.1); + } + } + + /// Network traffic: group netcode.gameobjects.profiler. + internal static class NetcodeGameObjectsProfilerCliCommands + { + private const string k_Group = NgoCliQueries.Group + ".profiler"; + private const string k_Tag = NetcodeGameObjectsCliCommands.Tag; + + [CliGroupCommand(k_Group, "Sample network traffic over N frames: bytes and messages sent and received, per message type, clients with round trip times, and the spawned object count. Requires a running session.", Tags = new[] { k_Tag })] + private static async Task GetStats( + [CliArg("frames", "Frames to sample.")] int frames = 120, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null, + [CliArg("timeout", "Stop sampling after this many seconds even if fewer frames ran.")] double timeoutInSeconds = 30) + { + var result = await NgoCliQueries.SampleStatsAsync(manager, frames, NetcodeGameObjectsCliCommands.WaitSeconds(timeoutInSeconds)); + return NgoCliOutput.ToWire(result); + } + } + + /// Starting, stopping and waiting on network sessions: group netcode.gameobjects.session. + internal static class NetcodeGameObjectsSessionCliCommands + { + private const string k_Group = NgoCliQueries.Group + ".session"; + private const string k_Tag = NetcodeGameObjectsCliCommands.Tag; + + [CliGroupCommand(k_Group, "Start the NetworkManager as host, server or client, optionally overriding the UnityTransport address and port. Requires play mode (editor.playmode start). For a client, follow with netcode.gameobjects.session wait_ready.", Tags = new[] { k_Tag })] + private static object Start( + [CliArg("mode", "Host, Server or Client.", Required = true)] NgoCliQueries.SessionMode mode, + [CliArg("address", "Address to connect to (client) or advertise. Defaults to the transport's.")] string address = null, + [CliArg("port", "Port. -1 keeps the transport's.")] int port = -1, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.Start(mode, manager, address, port)); + + [CliGroupCommand(k_Group, "Shut the NetworkManager down (any role). Clients disconnect; a host or server ends the session for everyone.", Tags = new[] { k_Tag })] + private static object Stop( + [CliArg("discard_message_queue", "Drop queued messages instead of sending them first.")] bool discardMessageQueue = false, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.Stop(manager, discardMessageQueue)); + + [CliGroupCommand(k_Group, "Disconnect a client from the server or host, with an optional reason the client receives.", Tags = new[] { k_Tag })] + private static object Kick( + [CliArg("client_id", "Client id, from netcode.gameobjects list_clients.", Required = true)] long clientId, + [CliArg("reason", "Disconnect reason sent to the client.")] string reason = null, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.Kick(clientId, reason, manager)); + + [CliGroupCommand(k_Group, "Shut a client down and connect it again with the same settings, then wait until it's connected. A client that was already disconnected (kicked or timed out) just connects again. Tests reconnection handling.", Tags = new[] { k_Tag })] + private static async Task Reconnect( + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null, + [CliArg("timeout", "Seconds to wait for the reconnection.")] double timeoutInSeconds = 30) + { + var result = await NgoCliQueries.ReconnectAsync(manager, NetcodeGameObjectsCliCommands.WaitSeconds(timeoutInSeconds)); + return NgoCliOutput.ToWire(result); + } + + [CliGroupCommand(k_Group, "Wait until the session is ready: a client connected and synchronized, or a server or host with at least min_clients remote clients. Returns ready false with the reason on timeout.", Tags = new[] { k_Tag })] + private static async Task WaitReady( + [CliArg("min_clients", "Server or host: remote clients to wait for. Distributed authority: other clients in the session.")] int minClients = 0, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null, + [CliArg("timeout", "Seconds to wait.")] double timeoutInSeconds = 30) + { + var result = await NgoCliQueries.WaitReadyAsync(manager, minClients, NetcodeGameObjectsCliCommands.WaitSeconds(timeoutInSeconds)); + return NgoCliOutput.ToWire(result); + } + } + + /// Simulated network conditions on UnityTransport: group netcode.gameobjects.simulator. + internal static class NetcodeGameObjectsSimulatorCliCommands + { + private const string k_Group = NgoCliQueries.Group + ".simulator"; + private const string k_Tag = NetcodeGameObjectsCliCommands.Tag; + + [CliGroupCommand(k_Group, "The simulated network conditions (preset, delay, jitter, loss, timeout, lag spike), the transports they apply to, and any Multiplayer Tools Network Simulator components in the scene.", Tags = new[] { k_Tag })] + private static object Get() + => NgoCliOutput.ToWire(NgoCliQueries.GetSimulator()); + + [CliGroupCommand(k_Group, "Simulate network conditions on UnityTransport: a preset and/or packet delay, jitter and loss. Applies live in play mode and to transports started later in this editor session.", Tags = new[] { k_Tag })] + private static object Set( + [CliArg("enable", "Turn the simulator on (true) or off (false).")] bool enable = true, + [CliArg("preset", "Preset name from netcode.gameobjects.simulator list_presets (needs Multiplayer Tools).")] string preset = null, + [CliArg("delay_ms", "Packet delay in milliseconds, 0-2000. -1 leaves it unchanged.")] int delayMs = -1, + [CliArg("jitter_ms", "Packet jitter in milliseconds, 0-200. -1 leaves it unchanged.")] int jitterMs = -1, + [CliArg("drop_percent", "Packet loss in percent, 0-100. -1 leaves it unchanged.")] int dropPercent = -1) + => NgoCliOutput.ToWire(NgoCliQueries.SetSimulator(enable, preset, delayMs, jitterMs, dropPercent)); + + [CliGroupCommand(k_Group, "Network condition presets (home broadband, mobile 2G to 5G…) with their delay, jitter and loss. They come from Multiplayer Tools.", Tags = new[] { k_Tag })] + private static object ListPresets() + => NgoCliOutput.ToWire(NgoCliQueries.ListSimulatorPresets()); + + [CliGroupCommand(k_Group, "Drop every packet for a short time, then restore the simulator settings. Tests how gameplay copes with a lag spike. Requires a running session.", Tags = new[] { k_Tag })] + private static object LagSpike( + [CliArg("duration_ms", "Spike length in milliseconds, 10-120000.")] int durationMs = 500) + => NgoCliOutput.ToWire(NgoCliQueries.StartLagSpike(durationMs)); + + [CliGroupCommand(k_Group, "Drop every packet until turned off, so connections time out. Tests disconnect handling. Requires a running session to turn on.", Tags = new[] { k_Tag })] + private static object Timeout( + [CliArg("enable", "true to start dropping everything, false to stop.")] bool enable = true) + => NgoCliOutput.ToWire(NgoCliQueries.SetTimeout(enable)); + } + + /// NetworkManager configuration: group netcode.gameobjects.config. + internal static class NetcodeGameObjectsConfigCliCommands + { + private const string k_Group = NgoCliQueries.Group + ".config"; + private const string k_Tag = NetcodeGameObjectsCliCommands.Tag; + + [CliGroupCommand(k_Group, "The NetworkManager's configuration: NetworkConfig fields (tick rate, connection approval, scene management, topology…), transport address and port, log level, player prefab, prefab lists, and the settable field names.", Tags = new[] { k_Tag })] + private static object Get( + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null) + => NgoCliOutput.ToWire(NgoCliQueries.GetConfig(manager)); + + [CliGroupCommand(k_Group, "Set one NetworkManager setting: a NetworkConfig field (snake_case, e.g. tick_rate), address, port, listen_address, player_prefab or log_level (Developer, Normal, Error, Nothing; applies live in play mode). Undoable in edit mode; saves nothing.", Tags = new[] { k_Tag })] + private static object Set( + [CliArg("field", "Field name, from settableFields of netcode.gameobjects.config get.", Required = true)] string field, + [CliArg("value", "New value, e.g. 60, true, DistributedAuthority or a prefab path.", Required = true)] string value, + [CliArg("manager", "NetworkManager name or hierarchy path, when there are several.")] string manager = null, + [CliArg("dry_run", "Validate and report without changing anything.")] bool dryRun = false) + => NgoCliOutput.ToWire(NgoCliQueries.SetConfig(field, value, manager, dryRun)); + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CLI/NetcodeGameObjectsCliCommands.cs.meta b/com.unity.netcode.gameobjects/Editor/CLI/NetcodeGameObjectsCliCommands.cs.meta new file mode 100644 index 0000000000..d0c2f1733a --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CLI/NetcodeGameObjectsCliCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 21aff7cac398b1943b27190b95ad17a3 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CLI/Unity.Netcode.GameObjects.CLI.Editor.asmdef b/com.unity.netcode.gameobjects/Editor/CLI/Unity.Netcode.GameObjects.CLI.Editor.asmdef new file mode 100644 index 0000000000..2eb8548954 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CLI/Unity.Netcode.GameObjects.CLI.Editor.asmdef @@ -0,0 +1,29 @@ +{ + "name": "Unity.Netcode.GameObjects.CLI.Editor", + "rootNamespace": "Unity.Netcode.GameObjects.Editor.Cli", + "references": [ + "Unity.Pipeline", + "Unity.Pipeline.Attributes", + "Unity.Netcode.Runtime", + "Unity.Netcode.GameObjects.Editor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_PIPELINE_PACKAGE_AVAILABLE" + ], + "versionDefines": [ + { + "name": "com.unity.pipeline", + "expression": "0.9.0-exp.1", + "define": "UNITY_PIPELINE_PACKAGE_AVAILABLE" + } + ], + "noEngineReferences": false +} diff --git a/com.unity.netcode.gameobjects/Editor/CLI/Unity.Netcode.GameObjects.CLI.Editor.asmdef.meta b/com.unity.netcode.gameobjects/Editor/CLI/Unity.Netcode.GameObjects.CLI.Editor.asmdef.meta new file mode 100644 index 0000000000..f622da2de5 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CLI/Unity.Netcode.GameObjects.CLI.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8a5916a7217c14c44ae9adbb762e2555 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries.meta b/com.unity.netcode.gameobjects/Editor/CliQueries.meta new file mode 100644 index 0000000000..3f4af59dcd --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 317f0a567afcd5048ad10ebf57362870 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliModels.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliModels.cs new file mode 100644 index 0000000000..e14bf80378 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliModels.cs @@ -0,0 +1,384 @@ +using System.Collections.Generic; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + // Result models for the Netcode for GameObjects CLI commands. Public fields in PascalCase; NgoCliOutput.ToWire + // turns them into camelCase JSON objects. A null field means "not available", not "zero". + + internal class StatusResult + { + public bool IsPlaying; + public List Managers = new List(); + public string Hint; + } + + internal class ManagerStatus + { + public string Name; + public string Path; + public string Scene; + public bool IsSingleton; + public bool IsListening; + /// host, server, client, or none when the manager isn't started. + public string Role; + public bool IsConnectedClient; + public bool IsApproved; + public long? LocalClientId; + public string Topology; + public bool DistributedAuthority; + public long? CurrentSessionOwner; + public bool IsSessionOwner; + public int? ConnectedClientCount; + public int? SpawnedObjectCount; + public uint TickRate; + public int? ServerTick; + public int? LocalTick; + public bool ShuttingDown; + public string DisconnectReason; + public string LogLevel; + public TransportSummary Transport; + public UnifiedNetcodeSummary UnifiedNetcode; + } + + internal class TransportSummary + { + public string Type; + public string Protocol; + public string Address; + public int? Port; + public string ListenAddress; + } + + internal class UnifiedNetcodeSummary + { + /// True when the package was compiled with Netcode for Entities 7.2.0 or newer (UNIFIED_NETCODE). + public bool Available; + /// True while this manager runs a hybrid prefab session on a Netcode for Entities world. + public bool? HybridSession; + } + + internal class ClientListResult + { + public string Manager; + public string Role; + public List Clients = new List(); + public string Note; + } + + internal class ClientInfo + { + public long ClientId; + public bool IsLocal; + public bool IsServer; + public bool IsSessionOwner; + public long? PlayerObjectId; + public int? OwnedObjectCount; + public long? RttMs; + } + + internal class ObjectListResult + { + public string Manager; + public string Role; + public int Total; + public int Returned; + public List Objects = new List(); + } + + internal class ObjectSummary + { + public long NetworkObjectId; + public string Name; + public long PrefabIdHash; + public long OwnerClientId; + public bool IsOwner; + public bool HasAuthority; + public bool IsPlayerObject; + public bool InScenePlaced; + public bool Hybrid; + public float[] Position; + } + + internal class ObjectDetail : ObjectSummary + { + public string Manager; + public string Scene; + public bool ActiveInHierarchy; + public bool DestroyWithScene; + public bool DontDestroyWithOwner; + public OwnershipInfo Ownership; + public List Observers; + public List Behaviours = new List(); + } + + internal class OwnershipInfo + { + public List Status = new List(); + public bool Transferable; + public bool Distributable; + public bool SessionOwnerOnly; + public bool RequestRequired; + public bool Locked; + public bool RequestInProgress; + /// Which peer may change ownership under the manager's topology. + public string WhoCanChange; + } + + internal class SpawnResult + { + public string Manager; + public string Prefab; + public ObjectDetail Object; + public List Notes = new List(); + } + + internal class DespawnResult + { + public string Manager; + public long NetworkObjectId; + public string Name; + public bool Despawned; + public bool Destroyed; + } + + internal class OwnershipChangeResult + { + public string Manager; + public long NetworkObjectId; + public string Name; + public long PreviousOwner; + public long Owner; + public string Topology; + public List Notes = new List(); + } + + internal class CreateNetworkManagerResult + { + public bool DryRun; + public bool Created; + public string Name; + public string Scene; + public string Transport; + public string Topology; + public string Address; + public int Port; + public List PrefabsLists = new List(); + public List Notes = new List(); + } + + internal class PrefabListResult + { + public bool GenerateDefaultNetworkPrefabs; + public string DefaultNetworkPrefabsPath; + public List Lists = new List(); + public List Managers = new List(); + } + + internal class PrefabsListInfo + { + public string Path; + public bool IsDefault; + public int Count; + public List Prefabs = new List(); + } + + internal class PrefabEntry + { + public string Name; + public string Path; + public long PrefabIdHash; + public string Override; + public string OverrideSource; + public string OverrideTarget; + public bool Hybrid; + public string Error; + } + + internal class ManagerPrefabs + { + public string Manager; + public string Scene; + public string PlayerPrefab; + public List Lists = new List(); + } + + internal class RegisterPrefabResult + { + public bool DryRun; + public string Prefab; + public string List; + public bool AlreadyRegistered; + public bool Registered; + public List Notes = new List(); + } + + internal class MakePrefabResult + { + public bool DryRun; + public string Prefab; + public bool AlreadyNetworkObject; + public bool AddedNetworkObject; + public long? PrefabIdHash; + public string RegisteredIn; + public List Notes = new List(); + } + + internal class AuditResult + { + public int PrefabsScanned; + public int Errors; + public int Warnings; + public List Issues = new List(); + public bool Truncated; + } + + internal class ValidationResult + { + public int ManagersChecked; + public int Errors; + public int Warnings; + public List Issues = new List(); + } + + internal class ProjectAuditResult + { + /// Set when NetworkManagers were checked. + public int? ManagersChecked; + /// Set when network prefabs were checked. + public int? PrefabsScanned; + public int Errors; + public int Warnings; + public bool Truncated; + public List Issues = new List(); + } + + internal class AuditIssue + { + /// error or warning. + public string Severity; + public string Code; + /// config (NetworkManagers) or prefabs. + public string Area; + public string Asset; + public string Message; + } + + internal class ConfigResult + { + public string Manager; + public string Scene; + public bool IsPlaying; + public Dictionary NetworkConfig = new Dictionary(); + public TransportSummary Transport; + public string LogLevel; + public string PlayerPrefab; + public List PrefabsLists = new List(); + public bool GenerateDefaultNetworkPrefabs; + public string DefaultNetworkPrefabsPath; + public List SettableFields = new List(); + } + + internal class ConfigSetResult + { + public bool DryRun; + public string Manager; + public string Field; + public object Previous; + public object Value; + public string AppliesWhen; + public List Notes = new List(); + } + + internal class SimulatorSettings + { + public bool Enabled; + public string Preset; + public int PacketDelayMs; + public int PacketJitterMs; + public int PacketDropPercent; + public bool TimeoutActive; + public int LagSpikeMsLeft; + } + + internal class SimulatorResult + { + public SimulatorSettings Previous; + public SimulatorSettings Current; + public List AppliedLiveTo = new List(); + public string AppliesWhen; + public List Notes = new List(); + } + + internal class SimulatorStatusResult + { + public SimulatorSettings Settings; + public bool MultiplayerToolsInstalled; + /// True when the installed Multiplayer Tools has the Network Simulator and its presets (2.0 or later). + public bool NetworkSimulatorAvailable; + public List NetworkSimulatorComponents = new List(); + public List LiveTransports = new List(); + public string AppliesWhen; + } + + internal class SimulatorPresetInfo + { + public string Name; + public string Description; + public int PacketDelayMs; + public int PacketJitterMs; + public int PacketLossPercent; + public int PacketLossInterval; + } + + internal class SimulatorPresetListResult + { + public string Source; + public List Presets = new List(); + public string Note; + } + + internal class SessionResult + { + public string Action; + public ManagerStatus Status; + public List Notes = new List(); + } + + internal class WaitReadyResult + { + public bool Ready; + public bool TimedOut; + public double WaitedSeconds; + public string Reason; + public ManagerStatus Status; + } + + internal class StatsResult + { + public string Manager; + public string Role; + public int Frames; + public double Seconds; + /// Bytes handed to and read from the transport, in message batches. + public long BytesSent; + public long BytesReceived; + public double BytesSentPerSecond; + public double BytesReceivedPerSecond; + public long MessagesSent; + public long MessagesReceived; + /// Traffic per message type, largest first. + public List Messages = new List(); + public List Clients = new List(); + public int? SpawnedObjectCount; + public string Note; + } + + internal class MessageStat + { + public string Message; + public long Sent; + public long Received; + public long BytesSent; + public long BytesReceived; + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliModels.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliModels.cs.meta new file mode 100644 index 0000000000..fed722f832 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliModels.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8be8725d551ffcb41ac6d8a98df10e23 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliOutput.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliOutput.cs new file mode 100644 index 0000000000..f254596366 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliOutput.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + /// + /// Turns the CLI result models into plain dictionaries and lists with camelCase keys, the shape pipeline + /// commands return. This keeps the models in Unity naming style and the package free of a JSON library + /// dependency. Enums become their names. Null fields are kept as explicit nulls, so a value that isn't + /// available reads differently from a misspelled field. + /// + internal static class NgoCliOutput + { + internal static object ToWire(object value) + { + switch (value) + { + case null: + return null; + case string or bool or char: + return value; + case Enum e: + return e.ToString(); + case IDictionary dictionary: + { + var result = new Dictionary(dictionary.Count); + foreach (DictionaryEntry entry in dictionary) + { + result[Convert.ToString(entry.Key, CultureInfo.InvariantCulture)] = ToWire(entry.Value); + } + return result; + } + case IEnumerable enumerable: + { + var result = new List(); + foreach (var item in enumerable) + { + result.Add(ToWire(item)); + } + return result; + } + } + + var type = value.GetType(); + if (type.IsPrimitive || value is decimal) + { + return value; + } + + var fields = new Dictionary(); + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public)) + { + fields[ToCamelCase(field.Name)] = ToWire(field.GetValue(value)); + } + return fields; + } + + internal static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name) || char.IsLower(name[0])) + { + return name; + } + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliOutput.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliOutput.cs.meta new file mode 100644 index 0000000000..e9b55d365b --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliOutput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a09444eecf69ba4a925e8976f2ef1e1 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Authoring.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Authoring.cs new file mode 100644 index 0000000000..de42cc83a3 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Authoring.cs @@ -0,0 +1,684 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Unity.Netcode.GameObjects.Editor.Configuration; +using Unity.Netcode.Transports.UTP; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + internal static partial class NgoCliQueries + { + private static readonly Regex k_GuidPattern = new Regex("^[0-9a-fA-F]{32}$"); + + // --- NetworkManager --- + + /// + /// Adds a NetworkManager with a UnityTransport to the active scene, set up the way the inspector would: + /// the default network prefabs list attached, the transport selected, the topology and address set. + /// Undoable; the scene is marked dirty but not saved. + /// + internal static CreateNetworkManagerResult CreateNetworkManager(string name, NetworkTopologyTypes topology, string address, int port, bool dryRun) + { + RequireEditMode("create a NetworkManager"); + RequireDefined(topology, "topology"); + if (port < 1 || port > ushort.MaxValue) + { + throw new ArgumentException($"port must be between 1 and {ushort.MaxValue}."); + } + if (string.IsNullOrWhiteSpace(address)) + { + throw new ArgumentException("address can't be empty, e.g. 127.0.0.1."); + } + var existing = FindManagers(); + if (existing.Count > 0) + { + throw new InvalidOperationException($"The open scenes already have a NetworkManager ({string.Join(", ", existing.Select(m => $"{GetHierarchyPath(m.transform)} in {SceneLabel(m.gameObject.scene)}"))}). A session uses one; inspect it with netcode.gameobjects.config get."); + } + + var scene = SceneManager.GetActiveScene(); + var result = new CreateNetworkManagerResult + { + DryRun = dryRun, + Name = string.IsNullOrWhiteSpace(name) ? "NetworkManager" : name.Trim(), + Scene = string.IsNullOrEmpty(scene.path) ? scene.name : scene.path, + Transport = nameof(UnityTransport), + Topology = topology.ToString(), + Address = address.Trim(), + Port = port, + }; + if (topology == NetworkTopologyTypes.DistributedAuthority) + { + result.Notes.Add("Distributed authority needs a session service (UseCMBService, e.g. through the Multiplayer Services SDK) or a distributed authority host."); + } + if (dryRun) + { + return result; + } + + Undo.IncrementCurrentGroup(); + var gameObject = new GameObject(result.Name); + Undo.RegisterCreatedObjectUndo(gameObject, "Create NetworkManager"); + SceneManager.MoveGameObjectToScene(gameObject, scene); + // Adding the component runs NetworkManager.Reset, which attaches the default network prefabs list + // when the project generates one. + var manager = Undo.AddComponent(gameObject); + var transport = Undo.AddComponent(gameObject); + manager.NetworkConfig ??= new NetworkConfig(); + manager.NetworkConfig.NetworkTransport = transport; + manager.NetworkConfig.NetworkTopology = topology; + var listenAddress = DefaultListenAddress(result.Address); + SetConnectionAddress(transport, result.Address, (ushort)port, listenAddress); + if (!string.IsNullOrEmpty(listenAddress)) + { + result.Notes.Add($"A server listens on every interface ({listenAddress}) so clients can reach it at {result.Address}. Change it with netcode.gameobjects.config set listen_address."); + } + EditorUtility.SetDirty(manager); + EditorUtility.SetDirty(transport); + EditorSceneManager.MarkSceneDirty(scene); + + result.Created = true; + result.PrefabsLists = manager.NetworkConfig.Prefabs.NetworkPrefabsLists.Where(l => l != null).Select(AssetDatabase.GetAssetPath).ToList(); + if (result.PrefabsLists.Count == 0) + { + result.Notes.Add("No network prefabs list attached (default prefab generation is off). Register prefabs with netcode.gameobjects register_prefab."); + } + result.Notes.Add("The scene is modified but not saved. Save it with scene save."); + return result; + } + + // --- Prefabs --- + + internal static PrefabListResult ListPrefabs(string listFilter) + { + var settings = NetcodeForGameObjectsProjectSettings.instance; + var result = new PrefabListResult + { + GenerateDefaultNetworkPrefabs = settings.GenerateDefaultNetworkPrefabs, + DefaultNetworkPrefabsPath = NetworkPrefabProcessor.DefaultNetworkPrefabsPath, + }; + + foreach (var list in FindPrefabsLists()) + { + var path = AssetDatabase.GetAssetPath(list); + if (!string.IsNullOrWhiteSpace(listFilter) && path.IndexOf(listFilter.Trim(), StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + var info = new PrefabsListInfo { Path = path, IsDefault = list.IsDefault, Count = list.PrefabList.Count }; + foreach (var prefab in list.PrefabList) + { + info.Prefabs.Add(DescribePrefabEntry(prefab)); + } + result.Lists.Add(info); + } + + foreach (var manager in FindManagers()) + { + var config = manager.NetworkConfig; + result.Managers.Add(new ManagerPrefabs + { + Manager = GetHierarchyPath(manager.transform), + Scene = manager.gameObject.scene.name, + PlayerPrefab = config?.PlayerPrefab != null ? AssetPathOrName(config.PlayerPrefab) : null, + Lists = config?.Prefabs?.NetworkPrefabsLists.Where(l => l != null).Select(AssetDatabase.GetAssetPath).ToList() ?? new List(), + }); + } + return result; + } + + /// + /// Describes a list entry, with an error when NGO would reject it at runtime (see + /// ). An empty entry, which netcode ignores, is described as such. + /// + private static PrefabEntry DescribePrefabEntry(NetworkPrefab prefab) + { + if (prefab == null) + { + return new PrefabEntry { Error = k_EmptyEntry }; + } + var entry = new PrefabEntry { Override = prefab.Override.ToString() }; + var source = SourcePrefab(prefab); + if (source == null && prefab.Override != NetworkPrefabOverride.Hash) + { + entry.Error = "Missing prefab reference."; + return entry; + } + + if (source != null) + { + entry.Name = source.name; + entry.Path = AssetDatabase.GetAssetPath(source); + if (source.TryGetComponent(out var networkObject)) + { + entry.PrefabIdHash = networkObject.GlobalObjectIdHash; + entry.Hybrid = IsHybridPrefab(source); + } + else + { + entry.Error = "The prefab has no NetworkObject on its root."; + } + } + if (prefab.Override == NetworkPrefabOverride.Hash) + { + entry.OverrideSource = prefab.SourceHashToOverride.ToString(); + if (prefab.SourceHashToOverride == 0) + { + entry.Error ??= "The hash override's source hash is 0."; + } + } + if (prefab.Override != NetworkPrefabOverride.None) + { + entry.OverrideSource ??= entry.Path; + var target = prefab.OverridingTargetPrefab; + entry.OverrideTarget = target != null ? AssetDatabase.GetAssetPath(target) : null; + if (target != null) + { + // A hash override has no source prefab to name the entry by. + entry.Name ??= target.name; + } + if (target == null) + { + entry.Error ??= "The override has no target prefab."; + } + else if (!target.TryGetComponent(out _)) + { + entry.Error ??= $"The override target {entry.OverrideTarget} has no NetworkObject on its root."; + } +#if UNIFIED_NETCODE + else if ((source != null && IsHybridPrefab(source)) || IsHybridPrefab(target)) + { + entry.Error ??= NetworkPrefab.HybridPrefabOverrideError; + } +#endif + } + return entry; + } + + private const string k_EmptyEntry = "Empty entry, with no prefab set. Netcode ignores it."; + + // The prefab an entry registers, as NGO resolves it: a prefab override falls back to Prefab when its source is + // unset. A hash override has none: NGO ignores the Prefab field the inspector keeps after switching to Hash. + private static GameObject SourcePrefab(NetworkPrefab prefab) => prefab.Override switch + { + NetworkPrefabOverride.Prefab => prefab.SourcePrefabToOverride ? prefab.SourcePrefabToOverride : prefab.Prefab, + NetworkPrefabOverride.Hash => null, + _ => prefab.Prefab, + }; + + /// + /// Whether a list already registers this prefab, as NGO matches entries at runtime: by the source prefab id + /// hash, so a prefab override or hash override of it counts. Empty entries are skipped. + /// + private static bool IsRegisteredIn(NetworkPrefabsList list, GameObject prefab) + { + var hash = prefab.TryGetComponent(out var networkObject) ? networkObject.GlobalObjectIdHash : 0; + foreach (var entry in list.PrefabList) + { + if (entry == null) + { + continue; + } + var source = SourcePrefab(entry); + if (source == prefab && entry.Override != NetworkPrefabOverride.Hash) + { + return true; + } + if (hash == 0) + { + continue; + } + var sourceHash = entry.Override == NetworkPrefabOverride.Hash + ? entry.SourceHashToOverride + : source != null && source.TryGetComponent(out var sourceObject) ? sourceObject.GlobalObjectIdHash : 0; + if (sourceHash == hash) + { + return true; + } + } + return false; + } + + /// + /// Adds a prefab to a NetworkPrefabsList: the named one, else the list of the scene's NetworkManager, else + /// the project's default list. With default prefab generation on, NetworkObject prefabs are usually in the + /// default list already, which is reported rather than treated as an error. + /// + internal static RegisterPrefabResult RegisterPrefab(string prefab, string listPath, bool dryRun) + { + RequireEditMode("register a network prefab"); + var prefabAsset = ResolvePrefab(prefab); + var prefabPath = AssetDatabase.GetAssetPath(prefabAsset); + if (prefabAsset.GetComponent() == null) + { + throw new ArgumentException($"{prefabPath} has no NetworkObject on its root. Add one with netcode.gameobjects make_prefab."); + } + + var result = new RegisterPrefabResult { DryRun = dryRun, Prefab = prefabPath }; + var registeredIn = FindPrefabsLists().Where(l => IsRegisteredIn(l, prefabAsset)).Select(AssetDatabase.GetAssetPath).ToList(); + var list = ResolveTargetList(listPath, dryRun, result.Notes); + result.List = list != null ? AssetDatabase.GetAssetPath(list) : NetworkPrefabProcessor.DefaultNetworkPrefabsPath; + + if (list != null && IsRegisteredIn(list, prefabAsset)) + { + result.AlreadyRegistered = true; + if (list.IsDefault && NetcodeForGameObjectsProjectSettings.instance.GenerateDefaultNetworkPrefabs) + { + result.Notes.Add("Default prefab generation keeps NetworkObject prefabs in this list automatically."); + } + return result; + } + if (registeredIn.Count > 0) + { + result.Notes.Add($"Also registered in: {string.Join(", ", registeredIn)}."); + } + if (dryRun) + { + return result; + } + + Undo.RecordObject(list, "Register network prefab"); + list.Add(new NetworkPrefab { Prefab = prefabAsset }); + EditorUtility.SetDirty(list); + AssetDatabase.SaveAssetIfDirty(list); + result.Registered = true; + return result; + } + + private static NetworkPrefabsList ResolveTargetList(string listPath, bool dryRun, List notes) + { + if (!string.IsNullOrWhiteSpace(listPath)) + { + var list = AssetDatabase.LoadAssetAtPath(listPath.Trim()) ?? throw new ArgumentException($"No NetworkPrefabsList at '{listPath}'. List them with netcode.gameobjects list_prefabs."); + return list; + } + + var managerLists = FindManagers() + .SelectMany(m => m.NetworkConfig?.Prefabs?.NetworkPrefabsLists ?? new List()) + .Where(l => l != null) + .Distinct() + .ToList(); + if (managerLists.Count == 1) + { + return managerLists[0]; + } + if (managerLists.Count > 1) + { + throw new ArgumentException($"The scene's NetworkManagers use several lists. Pass list with one of: {string.Join(", ", managerLists.Select(AssetDatabase.GetAssetPath))}."); + } + + var defaultPath = NetworkPrefabProcessor.DefaultNetworkPrefabsPath; + var defaultList = AssetDatabase.LoadAssetAtPath(defaultPath); + if (defaultList == null) + { + notes.Add($"{(dryRun ? "Would create" : "Created")} the default list at {defaultPath}. Attach it to your NetworkManager's Network Prefabs Lists (or let netcode.gameobjects create_network_manager do it)."); + if (dryRun) + { + return null; + } + defaultList = NetworkPrefabProcessor.GetOrCreateNetworkPrefabs(defaultPath, out _, false); + } + return defaultList; + } + + /// + /// Makes a prefab a network prefab by adding a NetworkObject to its root, and reports the prefab id hash + /// the import generates. + /// + internal static MakePrefabResult MakePrefab(string prefab, bool dryRun) + { + RequireEditMode("make a network prefab"); + var prefabAsset = ResolvePrefab(prefab); + var path = AssetDatabase.GetAssetPath(prefabAsset); + var result = new MakePrefabResult { DryRun = dryRun, Prefab = path }; + + if (prefabAsset.TryGetComponent(out var existing)) + { + result.AlreadyNetworkObject = true; + result.PrefabIdHash = existing.GlobalObjectIdHash; + result.RegisteredIn = FindPrefabsLists().Where(l => IsRegisteredIn(l, prefabAsset)).Select(AssetDatabase.GetAssetPath).FirstOrDefault(); + return result; + } + if (prefabAsset.GetComponentInChildren(true) != null) + { + throw new ArgumentException($"{path} contains a NetworkManager; a NetworkObject can't share a hierarchy with it."); + } + var nested = prefabAsset.GetComponentsInChildren(true); + if (nested.Length > 0) + { + // Nested NetworkObjects are only supported in scene objects: a network prefab doesn't spawn them. + throw new ArgumentException($"{path} has {nested.Length} child NetworkObject(s), and network prefabs don't support nested NetworkObjects. Make each of those children its own network prefab, or remove their NetworkObjects, then run make_prefab again."); + } + if (dryRun) + { + return result; + } + + using (var scope = new PrefabUtility.EditPrefabContentsScope(path)) + { + scope.prefabContentsRoot.AddComponent(); + } + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); + + var reloaded = AssetDatabase.LoadAssetAtPath(path); + result.AddedNetworkObject = true; + if (reloaded.TryGetComponent(out var added) && added.GlobalObjectIdHash != 0) + { + result.PrefabIdHash = added.GlobalObjectIdHash; + } + else + { + result.Notes.Add("The prefab id hash is generated on the next import; check it with netcode.gameobjects list_prefabs."); + } + result.RegisteredIn = FindPrefabsLists().Where(l => IsRegisteredIn(l, reloaded)).Select(AssetDatabase.GetAssetPath).FirstOrDefault(); + if (result.RegisteredIn == null) + { + result.Notes.Add("Not in a network prefabs list yet. Register it with netcode.gameobjects register_prefab."); + } + if (FindManagers().Count > 0) + { + result.Notes.Add("Instances already placed in scenes get their NetworkObject from the prefab; save the scenes so they keep it."); + } + return result; + } + + // --- Audit --- + + /// + /// Runs the NetworkManager checks (), the network prefab checks + /// (), or both, and returns their issues as one list, NetworkManagers first. + /// + internal static ProjectAuditResult Audit(string scope, string folder, int limit, string managerFilter) + { + var normalized = string.IsNullOrWhiteSpace(scope) ? "all" : scope.Trim().ToLowerInvariant(); + if (normalized is not ("all" or "config" or "prefabs")) + { + throw new ArgumentException("scope must be all, config or prefabs."); + } + if (limit < 1) + { + throw new ArgumentException("limit must be at least 1."); + } + + var result = new ProjectAuditResult(); + var issues = new List(); + if (normalized != "prefabs") + { + // A project check shouldn't fail because the scene with the NetworkManager isn't open. + if (normalized == "all" && string.IsNullOrWhiteSpace(managerFilter) && FindManagers().Count == 0) + { + result.ManagersChecked = 0; + result.Warnings++; + issues.Add(new AuditIssue { Severity = "warning", Code = "NO_NETWORKMANAGER", Area = "config", Message = NoManagerMessage }); + } + else + { + var config = ValidateConfig(managerFilter); + result.ManagersChecked = config.ManagersChecked; + result.Errors += config.Errors; + result.Warnings += config.Warnings; + foreach (var issue in config.Issues) + { + issue.Area = "config"; + issues.Add(issue); + } + } + } + if (normalized != "config") + { + var prefabs = AuditPrefabs(folder, limit); + result.PrefabsScanned = prefabs.PrefabsScanned; + result.Errors += prefabs.Errors; + result.Warnings += prefabs.Warnings; + result.Truncated = prefabs.Truncated; + foreach (var issue in prefabs.Issues) + { + issue.Area = "prefabs"; + issues.Add(issue); + } + } + if (issues.Count > limit) + { + result.Truncated = true; + } + result.Issues = issues.Take(limit).ToList(); + return result; + } + + /// + /// Checks network prefabs for setup problems the editor otherwise reports one at a time, in the inspector or + /// when a session starts: behaviours without a NetworkObject, nested or misplaced NetworkObjects, + /// NetworkManagers inside network prefabs, duplicate or missing prefab ids, and list entries that don't + /// resolve. Prefabs whose dependencies include no netcode script aren't loaded. + /// + internal static AuditResult AuditPrefabs(string folder, int limit) + { + if (limit < 1) + { + throw new ArgumentException("limit must be at least 1."); + } + if (!string.IsNullOrWhiteSpace(folder) && !AssetDatabase.IsValidFolder(folder.Trim())) + { + throw new ArgumentException($"No folder at '{folder}'. Pass a project folder such as Assets/Prefabs."); + } + + var result = new AuditResult(); + var lists = FindPrefabsLists(); + // NetworkManager registers its player prefab when it starts, list or not. + var playerPrefabs = new HashSet(FindManagers().Select(m => m.NetworkConfig?.PlayerPrefab).Where(p => p != null)); + var autoGenerate = NetcodeForGameObjectsProjectSettings.instance.GenerateDefaultNetworkPrefabs; + var hashes = new Dictionary(); + var scriptCache = new Dictionary(); + + void Add(string severity, string code, string asset, string message) + { + if (severity == "error") + { + result.Errors++; + } + else + { + result.Warnings++; + } + if (result.Issues.Count < limit) + { + result.Issues.Add(new AuditIssue { Severity = severity, Code = code, Asset = asset, Message = message }); + } + else + { + result.Truncated = true; + } + } + + var searchIn = string.IsNullOrWhiteSpace(folder) ? new[] { "Assets" } : new[] { folder.Trim() }; + foreach (var path in AssetDatabase.FindAssets("t:Prefab", searchIn).Select(AssetDatabase.GUIDToAssetPath).Distinct()) + { + if (!UsesNetcodeScripts(path, scriptCache)) + { + continue; + } + var prefabAsset = AssetDatabase.LoadAssetAtPath(path); + if (prefabAsset == null) + { + continue; + } + result.PrefabsScanned++; + AuditPrefab(prefabAsset, path, lists, playerPrefabs, autoGenerate, hashes, Add); + } + + foreach (var list in lists) + { + var listPath = AssetDatabase.GetAssetPath(list); + foreach (var entry in list.PrefabList) + { + if (entry == null) + { + Add("warning", "LIST_ENTRY_INVALID", listPath, k_EmptyEntry); + continue; + } + var described = DescribePrefabEntry(entry); + if (described.Error != null) + { + Add("error", "LIST_ENTRY_INVALID", listPath, $"{described.Name ?? "An entry"}: {described.Error}"); + } + } + } + return result; + } + + private static void AuditPrefab(GameObject prefabAsset, string path, List lists, HashSet playerPrefabs, bool autoGenerate, Dictionary hashes, Action add) + { + var rootObject = prefabAsset.GetComponent(); + var networkObjects = prefabAsset.GetComponentsInChildren(true); + var behaviours = prefabAsset.GetComponentsInChildren(true); + + if (prefabAsset.GetComponentInChildren(true) != null && networkObjects.Length > 0) + { + add("error", "NETWORKMANAGER_WITH_NETWORKOBJECT", path, "A NetworkManager and a NetworkObject share this prefab's hierarchy. Keep the NetworkManager on its own GameObject."); + } + + foreach (var behaviour in behaviours) + { + if (behaviour == null || behaviour.GetComponentInParent(true) != null) + { + continue; + } + add("error", "BEHAVIOUR_WITHOUT_NETWORKOBJECT", path, $"{behaviour.GetType().Name} on '{GetHierarchyPath(behaviour.transform)}' has no NetworkObject on itself or a parent, so it never spawns. Add one with netcode.gameobjects make_prefab."); + } + + if (rootObject == null && networkObjects.Length > 0) + { + add("warning", "NETWORKOBJECT_NOT_ON_ROOT", path, $"The NetworkObject is on '{GetHierarchyPath(networkObjects[0].transform)}', not the root, so the prefab can't be registered or spawned as a whole."); + } + else if (rootObject != null && networkObjects.Length > 1) + { + add("warning", "NESTED_NETWORKOBJECT", path, $"{networkObjects.Length - 1} child NetworkObject(s). They aren't spawned with the prefab; spawn them separately or remove them."); + } + + if (rootObject == null) + { + return; + } + + var hash = rootObject.GlobalObjectIdHash; + if (hash == 0) + { + add("error", "MISSING_PREFAB_ID", path, "The NetworkObject has no prefab id hash. Reimport the prefab (asset import) to generate it."); + } + else if (hashes.TryGetValue(hash, out var other)) + { + add("error", "DUPLICATE_PREFAB_ID", path, $"Same prefab id hash ({hash}) as {other}. Reimport one of them (asset import) to regenerate it."); + } + else + { + hashes[hash] = path; + } + + if (!autoGenerate && !playerPrefabs.Contains(prefabAsset) && !lists.Any(l => IsRegisteredIn(l, prefabAsset))) + { + add("warning", "NOT_REGISTERED", path, "Not in any network prefabs list, so it can't be spawned. Register it with netcode.gameobjects register_prefab."); + } + } + + /// Whether the prefab depends on NetworkObject, NetworkManager or a NetworkBehaviour script. + private static bool UsesNetcodeScripts(string prefabPath, Dictionary cache) + { + foreach (var dependency in AssetDatabase.GetDependencies(prefabPath, true)) + { + if (!dependency.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (!cache.TryGetValue(dependency, out var isNetcode)) + { + var type = AssetDatabase.LoadAssetAtPath(dependency)?.GetClass(); + // A script whose class can't be resolved (a file holding several classes) might be netcode: check the prefab. + isNetcode = type == null || typeof(NetworkBehaviour).IsAssignableFrom(type) || type == typeof(NetworkObject) || type == typeof(NetworkManager); + cache[dependency] = isNetcode; + } + if (isNetcode) + { + return true; + } + } + return false; + } + + // --- Helpers --- + + internal static List FindPrefabsLists() + { + return AssetDatabase.FindAssets($"t:{nameof(NetworkPrefabsList)}") + .Select(AssetDatabase.GUIDToAssetPath) + .Distinct() + .Select(AssetDatabase.LoadAssetAtPath) + .Where(l => l != null) + .ToList(); + } + + /// Resolves a prefab path, GUID or name to the prefab asset. + internal static GameObject ResolvePrefab(string prefab) + { + if (string.IsNullOrWhiteSpace(prefab)) + { + throw new ArgumentException("Pass the prefab as a path (Assets/…/Name.prefab), GUID or name. List network prefabs with netcode.gameobjects list_prefabs."); + } + + prefab = prefab.Trim(); + string path; + if (prefab.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + path = prefab; + } + else if (k_GuidPattern.IsMatch(prefab)) + { + path = AssetDatabase.GUIDToAssetPath(prefab); + } + else + { + var matches = AssetDatabase.FindAssets($"{prefab} t:Prefab") + .Select(AssetDatabase.GUIDToAssetPath) + .Where(p => string.Equals(Path.GetFileNameWithoutExtension(p), prefab, StringComparison.OrdinalIgnoreCase)) + .Distinct() + .ToList(); + if (matches.Count > 1) + { + throw new ArgumentException($"Several prefabs are named '{prefab}': {string.Join(", ", matches)}. Pass the path instead."); + } + path = matches.FirstOrDefault(); + } + + var asset = (string.IsNullOrEmpty(path) ? null : AssetDatabase.LoadAssetAtPath(path)) ?? throw new ArgumentException($"No prefab found for '{prefab}'. Search with asset find, or list network prefabs with netcode.gameobjects list_prefabs."); + return asset; + } + + private static bool IsHybridPrefab(GameObject prefab) + { +#if UNIFIED_NETCODE + // NetworkObject serializes whether its GameObject has a GhostObject, which marks a hybrid prefab. + return prefab.TryGetComponent(out var networkObject) && networkObject.HasGhost; +#else + return false; +#endif + } + + private static string AssetPathOrName(UnityEngine.Object asset) + { + var path = AssetDatabase.GetAssetPath(asset); + return string.IsNullOrEmpty(path) ? asset.name : path; + } + + private static void RequireEditMode(string action) + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + throw new InvalidOperationException($"Can't {action} in play mode; the change would be lost. Exit play mode with editor.playmode stop first."); + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Authoring.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Authoring.cs.meta new file mode 100644 index 0000000000..55bce9ae07 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Authoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0751d5d0204127a45b828056756b8738 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Config.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Config.cs new file mode 100644 index 0000000000..aaad55a306 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Config.cs @@ -0,0 +1,440 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Unity.Netcode.GameObjects.Editor.Configuration; +using Unity.Netcode.Transports.UTP; +using UnityEditor; +using UnityEditor.SceneManagement; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + internal static partial class NgoCliQueries + { + // Transport and manager settings that config set accepts besides NetworkConfig's own fields. + private const string k_Address = "address"; + private const string k_Port = "port"; + private const string k_ListenAddress = "listen_address"; + private const string k_PlayerPrefab = "player_prefab"; + private const string k_LogLevel = "log_level"; + + // --- Get --- + + internal static ConfigResult GetConfig(string managerFilter) + { + var manager = ResolveManager(managerFilter); + var config = manager.NetworkConfig ?? new NetworkConfig(); + var result = new ConfigResult + { + Manager = GetHierarchyPath(manager.transform), + Scene = manager.gameObject.scene.name, + IsPlaying = EditorApplication.isPlaying, + Transport = DescribeTransport(config.NetworkTransport), + LogLevel = manager.LogLevel.ToString(), + PlayerPrefab = config.PlayerPrefab != null ? AssetPathOrName(config.PlayerPrefab) : null, + PrefabsLists = config.Prefabs?.NetworkPrefabsLists.Where(l => l != null).Select(AssetDatabase.GetAssetPath).ToList() ?? new List(), + GenerateDefaultNetworkPrefabs = NetcodeForGameObjectsProjectSettings.instance.GenerateDefaultNetworkPrefabs, + DefaultNetworkPrefabsPath = NetworkPrefabProcessor.DefaultNetworkPrefabsPath, + }; + foreach (var field in SettableConfigFields()) + { + var value = field.GetValue(config); + result.NetworkConfig[ToSnakeCase(field.Name)] = value is Enum ? value.ToString() : value; + } + result.SettableFields = SettableConfigFields().Select(f => ToSnakeCase(f.Name)) + .Concat(new[] { k_Address, k_Port, k_ListenAddress, k_PlayerPrefab, k_LogLevel }) + .ToList(); + return result; + } + + // --- Set --- + + /// + /// Sets one NetworkManager setting: a NetworkConfig field (snake_case name), the UnityTransport address, + /// port or listen address, the player prefab, or the log level. In edit mode the change is undoable and + /// the scene is marked dirty. In play mode it applies to the live NetworkManager, which must be stopped, + /// and is lost when play mode ends. + /// + internal static ConfigSetResult SetConfig(string field, string value, string managerFilter, bool dryRun) + { + if (string.IsNullOrWhiteSpace(field)) + { + throw new ArgumentException("Pass the field to set. List them with netcode.gameobjects.config get (settableFields)."); + } + var manager = ResolveManager(managerFilter); + var name = field.Trim().ToLowerInvariant(); + var playing = EditorApplication.isPlaying; + if (playing && (manager.IsListening || manager.ShutdownInProgress) && name != k_LogLevel) + { + throw new InvalidOperationException($"{manager.name} is running; its configuration is read when it starts. Stop it (netcode.gameobjects.session stop), set the field, then start it again."); + } + + manager.NetworkConfig ??= new NetworkConfig(); + var result = new ConfigSetResult + { + DryRun = dryRun, + Manager = GetHierarchyPath(manager.transform), + Field = name, + AppliesWhen = playing + ? "The next time this NetworkManager starts in this play session. Not saved: exit play mode and set it again to keep it." + : "The next time the NetworkManager starts. The scene is modified but not saved; save it with scene save.", + }; + + UnityEngine.Object target; + Action apply; + switch (name) + { + case k_Address: + case k_Port: + case k_ListenAddress: + { + if (manager.NetworkConfig.NetworkTransport is not UnityTransport utp) + { + throw new ArgumentException($"{name} is a UnityTransport setting, and {manager.name} uses {manager.NetworkConfig.NetworkTransport?.GetType().Name ?? "no transport"}."); + } + RequireDirectConnection(utp, name); + var data = utp.ConnectionData; + target = utp; + if (name == k_Port) + { + var port = (ushort)ParseValue(typeof(ushort), value, name); + if (port == 0) + { + throw new ArgumentException("port must be between 1 and 65535."); + } + result.Previous = (int)data.Port; + result.Value = (int)port; + apply = () => SetConnectionAddress(utp, data.Address, port, data.ServerListenAddress); + } + else if (name == k_Address) + { + var address = RequireText(value, name); + result.Previous = data.Address; + result.Value = address; + apply = () => SetConnectionAddress(utp, address, data.Port, data.ServerListenAddress); + } + else + { + var listen = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + result.Previous = data.ServerListenAddress; + result.Value = listen; + apply = () => SetConnectionAddress(utp, data.Address, data.Port, listen); + } + break; + } + case k_PlayerPrefab: + { + target = manager; + var prefab = string.IsNullOrWhiteSpace(value) || value.Trim() == "none" ? null : ResolvePrefab(value); + if (prefab != null && prefab.GetComponent() == null) + { + throw new ArgumentException($"{AssetDatabase.GetAssetPath(prefab)} has no NetworkObject on its root. Add one with netcode.gameobjects make_prefab."); + } + result.Previous = manager.NetworkConfig.PlayerPrefab != null ? AssetPathOrName(manager.NetworkConfig.PlayerPrefab) : null; + result.Value = prefab != null ? AssetDatabase.GetAssetPath(prefab) : null; + apply = () => manager.NetworkConfig.PlayerPrefab = prefab; + break; + } + case k_LogLevel: + { + var level = (LogLevel)ParseValue(typeof(LogLevel), value, name); + target = manager; + result.Previous = manager.LogLevel.ToString(); + result.Value = level.ToString(); + apply = () => manager.LogLevel = level; + if (playing) + { + result.AppliesWhen = "Now. Not saved: exit play mode and set it again to keep it."; + } + break; + } + default: + { + var configField = SettableConfigFields().FirstOrDefault(f => ToSnakeCase(f.Name) == name) ?? throw new ArgumentException($"Unknown field '{field}'. Settable: {string.Join(", ", GetConfig(managerFilter).SettableFields)}."); + var parsed = ParseValue(configField.FieldType, value, name); + ValidateConfigValue(configField.Name, name, parsed, result.Notes); + target = manager; + var previous = configField.GetValue(manager.NetworkConfig); + result.Previous = previous is Enum ? previous.ToString() : previous; + result.Value = parsed is Enum ? parsed.ToString() : parsed; + apply = () => configField.SetValue(manager.NetworkConfig, parsed); + break; + } + } + + if (dryRun) + { + return result; + } + if (!playing) + { + Undo.IncrementCurrentGroup(); + Undo.RecordObject(target, $"Set NetworkManager {name}"); + } + apply(); + if (!playing) + { + // A change made directly to a prefab instance is only kept once recorded as an override. + if (PrefabUtility.IsPartOfPrefabInstance(target)) + { + PrefabUtility.RecordPrefabInstancePropertyModifications(target); + } + EditorUtility.SetDirty(target); + EditorSceneManager.MarkSceneDirty(manager.gameObject.scene); + } + return result; + } + + private static void ValidateConfigValue(string fieldName, string name, object value, List notes) + { + switch (fieldName) + { + case nameof(NetworkConfig.TickRate) when (uint)value == 0 || (uint)value > 1000: + throw new ArgumentException("tick_rate must be between 1 and 1000."); + case nameof(NetworkConfig.TickRate) when (uint)value > 120: + notes.Add("Tick rates above 120 cost a lot of bandwidth and CPU; 30 to 60 is typical."); + break; + case nameof(NetworkConfig.NetworkTopology) when (NetworkTopologyTypes)value == NetworkTopologyTypes.DistributedAuthority: + notes.Add("Distributed authority needs a session service (use_cmb_service, e.g. through the Multiplayer Services SDK) or a distributed authority host."); + break; + case nameof(NetworkConfig.SpawnTimeout) when (float)value < NetworkConfig.MinSpawnTimeout || (float)value > NetworkConfig.MaxSpawnTimeout: + throw new ArgumentException($"{name} must be between {NetworkConfig.MinSpawnTimeout} and {NetworkConfig.MaxSpawnTimeout} seconds."); + case nameof(NetworkConfig.ClientConnectionBufferTimeout) or nameof(NetworkConfig.LoadSceneTimeOut) or nameof(NetworkConfig.TimeResyncInterval) when (int)value < 1: + throw new ArgumentException($"{name} must be at least 1."); + } + } + + // --- Validate --- + + /// + /// Checks NetworkManager setups for problems that otherwise only surface when a session starts. + /// + internal static ValidationResult ValidateConfig(string managerFilter) + { + var result = new ValidationResult(); + void Add(string severity, string code, string asset, string message) + { + if (severity == "error") + { + result.Errors++; + } + else + { + result.Warnings++; + } + result.Issues.Add(new AuditIssue { Severity = severity, Code = code, Asset = asset, Message = message }); + } + + var all = FindManagers(); + var managers = string.IsNullOrWhiteSpace(managerFilter) ? all : new List { ResolveManager(managerFilter, all) }; + if (managers.Count == 0) + { + throw new InvalidOperationException(NoManagerMessage); + } + if (all.Count > 1) + { + Add("warning", "SEVERAL_NETWORKMANAGERS", null, $"{all.Count} NetworkManagers in the open scenes. Only one becomes NetworkManager.Singleton; keep one per session."); + } + + foreach (var manager in managers) + { + ValidateManager(manager, Add); + } + result.ManagersChecked = managers.Count; + return result; + } + + private static void ValidateManager(NetworkManager manager, Action add) + { + var where = $"{SceneLabel(manager.gameObject.scene)}/{GetHierarchyPath(manager.transform)}"; + var config = manager.NetworkConfig; + if (config == null) + { + add("error", "NO_NETWORK_CONFIG", where, "The NetworkManager has no NetworkConfig. Reset the component, or recreate it with netcode.gameobjects create_network_manager."); + return; + } + + if (manager.transform.parent != null) + { + add("error", "NETWORKMANAGER_NOT_ROOT", where, "The NetworkManager isn't a root GameObject. It moves to DontDestroyOnLoad when it starts, which a nested GameObject can't do."); + } + if (manager.GetComponentInChildren(true) != null || manager.GetComponentInParent(true) != null) + { + add("error", "NETWORKMANAGER_WITH_NETWORKOBJECT", where, "A NetworkObject shares the NetworkManager's hierarchy. Keep the NetworkManager on its own GameObject."); + } + + if (config.NetworkTransport == null) + { + add("error", "NO_TRANSPORT", where, "No transport selected. Add a UnityTransport and select it (netcode.gameobjects create_network_manager sets one up)."); + } + if (config.NetworkTopology == NetworkTopologyTypes.DistributedAuthority) + { + if (config.NetworkTransport != null && config.NetworkTransport is not UnityTransport) + { + add("error", "DA_NEEDS_UNITYTRANSPORT", where, "Distributed authority only supports UnityTransport."); + } + if (!config.UseCMBService) + { + add("warning", "DA_WITHOUT_SERVICE", where, "Distributed authority without use_cmb_service needs a distributed authority host; sessions usually go through the Multiplayer Services SDK."); + } + } + + // The dialog opens on entering play mode; in play mode the manager has moved to DontDestroyOnLoad. + var scene = manager.gameObject.scene; + var checkScene = config.EnableSceneManagement && !EditorApplication.isPlaying; + if (checkScene && !string.IsNullOrEmpty(scene.path) && !EditorBuildSettings.scenes.Any(s => s.enabled && s.path == scene.path)) + { + add("warning", "SCENE_NOT_IN_BUILD", where, $"Scene management is on and {scene.path} isn't in the build's scene list, so clients can't load it. Entering play mode opens a dialog asking to add it; add it first with scene add_to_build, or turn scene management off (netcode.gameobjects.config set enable_scene_management false)."); + } + else if (checkScene && string.IsNullOrEmpty(scene.path)) + { + add("warning", "SCENE_NOT_SAVED", where, "Scene management is on and the scene isn't saved, so it can't be in the build's scene list; entering play mode opens a dialog. Save it (scene save) and add it to the build (scene add_to_build)."); + } + + if (config.TickRate == 0) + { + add("error", "TICK_RATE_ZERO", where, "tick_rate is 0. Set it with netcode.gameobjects.config set tick_rate 30."); + } + + var lists = config.Prefabs?.NetworkPrefabsLists.Where(l => l != null).ToList() ?? new List(); + if (config.Prefabs != null && config.Prefabs.NetworkPrefabsLists.Any(l => l == null)) + { + add("error", "MISSING_PREFABS_LIST", where, "A Network Prefabs Lists entry is empty or points to a deleted asset."); + } + if (lists.Count == 0 && config.PlayerPrefab == null) + { + add("warning", "NO_NETWORK_PREFABS", where, "No network prefabs list and no player prefab: nothing can be spawned. Register prefabs with netcode.gameobjects register_prefab."); + } + if (config.PlayerPrefab != null && config.PlayerPrefab.GetComponent() == null) + { + add("error", "PLAYER_PREFAB_WITHOUT_NETWORKOBJECT", where, $"The player prefab {AssetPathOrName(config.PlayerPrefab)} has no NetworkObject on its root."); + } + + // NGO rejects overrides of hybrid prefabs (audit reports them), so only plain entries can make the session hybrid. + var prefabs = lists.SelectMany(l => l.PrefabList).Where(p => p != null && p.Override == NetworkPrefabOverride.None).Select(p => p.Prefab).Where(p => p != null).ToList(); + if (config.PlayerPrefab != null) + { + prefabs.Add(config.PlayerPrefab); + } + var hybrid = prefabs.Where(IsHybridPrefab).Distinct().ToList(); + if (hybrid.Count > 0) + { + ValidateHybrid(config, hybrid, where, add); + } + } + + private static void ValidateHybrid(NetworkConfig config, List hybrid, string where, Action add) + { +#if UNIFIED_NETCODE + var names = string.Join(", ", hybrid.Take(5).Select(AssetPathOrName)); + if (config.NetworkTopology == NetworkTopologyTypes.DistributedAuthority) + { + add("error", "HYBRID_WITH_DISTRIBUTED_AUTHORITY", where, $"{NetworkPrefabs.DistributedAuthorityHybridPrefabError} Hybrid prefabs: {names}."); + } + + var netcodeConfig = FindNetcodeConfig(); + if (netcodeConfig == null) + { + add("error", "HYBRID_WITHOUT_NETCODECONFIG", where, $"Hybrid prefabs ({names}) run on Netcode for Entities and need a NetcodeConfig set to a single world. Create one in Project Settings > Multiplayer."); + } + else if (HybridNetcodeDefaults.IsMissingRequired(netcodeConfig, out var reason)) + { + add("error", "HYBRID_NETCODECONFIG", AssetDatabase.GetAssetPath(netcodeConfig), $"The NetcodeConfig can't be used with hybrid prefabs: {reason}."); + } +#endif + } + +#if UNIFIED_NETCODE + private static NetcodeConfig FindNetcodeConfig() + { + if (NetcodeConfig.Global != null) + { + return NetcodeConfig.Global; + } + var configs = AssetDatabase.FindAssets($"t:{nameof(NetcodeConfig)}") + .Select(AssetDatabase.GUIDToAssetPath) + .Select(AssetDatabase.LoadAssetAtPath) + .Where(c => c != null) + .ToList(); + // A project has one NetcodeConfig (Netcode for Entities reports several as an error). + return configs.FirstOrDefault(); + } +#endif + + // --- Helpers --- + + private static IEnumerable SettableConfigFields() + { + return typeof(NetworkConfig).GetFields(BindingFlags.Instance | BindingFlags.Public) + .Where(f => !f.IsInitOnly && (f.FieldType.IsPrimitive || f.FieldType.IsEnum)) + .OrderBy(f => f.MetadataToken); + } + + private static object ParseValue(Type type, string value, string name) + { + var text = RequireText(value, name); + try + { + if (type.IsEnum) + { + var match = Enum.GetNames(type).FirstOrDefault(n => string.Equals(n, text, StringComparison.OrdinalIgnoreCase)) ?? throw new FormatException(); + return Enum.Parse(type, match); + } + if (type == typeof(bool)) + { + return text.ToLowerInvariant() switch + { + "true" or "1" or "on" or "yes" => true, + "false" or "0" or "off" or "no" => false, + _ => throw new FormatException(), + }; + } + var parsed = Convert.ChangeType(text, type, CultureInfo.InvariantCulture); + if (parsed is float f && !float.IsFinite(f) || parsed is double d && !double.IsFinite(d)) + { + throw new FormatException(); + } + return parsed; + } + catch (Exception e) when (e is FormatException or OverflowException or InvalidCastException) + { + var expected = type.IsEnum ? $"one of {string.Join(", ", Enum.GetNames(type))}" : type == typeof(bool) ? "true or false" : $"a {type.Name} number"; + throw new ArgumentException($"{name} must be {expected}, not '{value}'."); + } + } + + private static string RequireText(string value, string name) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Pass a value for {name}."); + } + return value.Trim(); + } + + internal static string ToSnakeCase(string name) + { + var builder = new System.Text.StringBuilder(name.Length + 8); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c)) + { + // Keep acronyms together: "UseCMBService" -> "use_cmb_service". + var startsWord = i > 0 && (!char.IsUpper(name[i - 1]) || (i + 1 < name.Length && char.IsLower(name[i + 1]))); + if (startsWord) + { + builder.Append('_'); + } + builder.Append(char.ToLowerInvariant(c)); + } + else + { + builder.Append(c); + } + } + return builder.ToString(); + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Config.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Config.cs.meta new file mode 100644 index 0000000000..da26737eab --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Config.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e719e03d27fa3424d98b87bc841d5d49 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Session.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Session.cs new file mode 100644 index 0000000000..1408ae3600 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Session.cs @@ -0,0 +1,504 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.Netcode.Transports.UTP; +using UnityEditor; +using UnityEngine; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + internal static partial class NgoCliQueries + { + internal enum SessionMode + { + Host, + Server, + Client, + } + + // --- Start / stop --- + + /// + /// Starts the NetworkManager as host, server or client, optionally pointing its UnityTransport at another + /// address and port first. Returns once the start call returns; use to wait + /// for the connection. + /// + internal static SessionResult Start(SessionMode mode, string managerFilter, string address, int port) + { + RequireDefined(mode, "mode"); + if (!EditorApplication.isPlaying) + { + throw new InvalidOperationException("Requires play mode. Enter it with editor.playmode start first."); + } + if (port > ushort.MaxValue) + { + throw new ArgumentException($"port must be between 1 and {ushort.MaxValue} (or -1 to keep the configured port)."); + } + + var manager = ResolveManager(managerFilter); + if (manager.IsListening || manager.ShutdownInProgress) + { + throw new InvalidOperationException($"{manager.name} is already running as {GetRole(manager)}. Stop it first with netcode.gameobjects.session stop."); + } + + var result = new SessionResult { Action = "start" }; + if (!string.IsNullOrWhiteSpace(address) || port > 0) + { + if (manager.NetworkConfig.NetworkTransport is not UnityTransport utp) + { + throw new ArgumentException("address and port can only be set on a UnityTransport. Configure your transport directly."); + } + RequireDirectConnection(utp, "address and port"); + var data = utp.ConnectionData; + SetConnectionAddress(utp, string.IsNullOrWhiteSpace(address) ? data.Address : address.Trim(), port > 0 ? (ushort)port : data.Port, data.ServerListenAddress); + result.Notes.Add($"Transport set to {utp.ConnectionData.Address}:{utp.ConnectionData.Port} for this session."); + } + + bool started; + using (var errors = new LogCapture()) + { + started = mode switch + { + SessionMode.Host => manager.StartHost(), + SessionMode.Server => manager.StartServer(), + _ => manager.StartClient(), + }; + if (!started) + { + throw new InvalidOperationException($"{manager.name} failed to start as {mode.ToString().ToLowerInvariant()}{errors.Describe()}"); + } + } + + result.Status = DescribeManager(manager); + if (mode == SessionMode.Client) + { + result.Notes.Add("The client is connecting. Wait for it with netcode.gameobjects.session wait_ready."); + } + return result; + } + + internal static SessionResult Stop(string managerFilter, bool discardMessageQueue) + { + if (!EditorApplication.isPlaying) + { + throw new InvalidOperationException(NotPlayingMessage); + } + var manager = ResolveManager(managerFilter); + var result = new SessionResult { Action = "stop" }; + if (!manager.IsListening && !manager.ShutdownInProgress) + { + result.Notes.Add($"{manager.name} wasn't running."); + } + else + { + manager.Shutdown(discardMessageQueue); + result.Notes.Add("Shutdown finishes on the next network update; netcode.gameobjects status shows when it's done."); + } + result.Status = DescribeManager(manager); + return result; + } + + /// Disconnects a remote client from the server or host. + internal static SessionResult Kick(long clientId, string reason, string managerFilter) + { + var manager = ResolveRunningManager(managerFilter); + if (!manager.IsServer) + { + throw new InvalidOperationException($"{manager.name} is a {GetRole(manager)}; only the server or host can disconnect other clients. Run the command against the server's editor, or stop this client with netcode.gameobjects.session stop."); + } + if (clientId < 0 || !manager.ConnectedClientsIds.Contains((ulong)clientId)) + { + throw new ArgumentException($"No connected client {clientId}. Connected: {string.Join(", ", manager.ConnectedClientsIds)}. List them with netcode.gameobjects list_clients."); + } + if ((ulong)clientId == manager.LocalClientId) + { + throw new ArgumentException("That's the host's own client. Stop the host with netcode.gameobjects.session stop instead."); + } + + manager.DisconnectClient((ulong)clientId, string.IsNullOrWhiteSpace(reason) ? null : reason); + var result = new SessionResult { Action = "kick", Status = DescribeManager(manager) }; + result.Notes.Add($"Client {clientId} disconnected. Its objects are despawned or redistributed according to DontDestroyWithOwner."); + return result; + } + + /// + /// Shuts a client down and starts it again with the same transport settings, then waits for it to be + /// connected and approved. + /// + internal static async Task ReconnectAsync(string managerFilter, float timeoutSeconds) + { + ValidateTimeout(timeoutSeconds); + var manager = ResolveManager(managerFilter); + if (!EditorApplication.isPlaying) + { + throw new InvalidOperationException(NotPlayingMessage); + } + if (manager.IsServer) + { + throw new InvalidOperationException($"{manager.name} is the {GetRole(manager)}. reconnect is for clients; restart a server with netcode.gameobjects.session stop, then start."); + } + + var started = EditorApplication.timeSinceStartup; + // A client that was kicked or timed out has already shut down; connect it again as it is. + if (manager.IsListening || manager.ShutdownInProgress) + { + manager.Shutdown(); + await WaitUntilAsync(() => !manager.IsListening && !manager.ShutdownInProgress, timeoutSeconds, "the client to shut down"); + } + + using (var errors = new LogCapture()) + { + if (!manager.StartClient()) + { + throw new InvalidOperationException($"{manager.name} shut down but failed to start again{errors.Describe()}"); + } + } + + var remaining = timeoutSeconds - (float)(EditorApplication.timeSinceStartup - started); + var ready = await WaitReadyAsync(manager, Math.Max(remaining, 0.1f), 0); + ready.WaitedSeconds = EditorApplication.timeSinceStartup - started; + return ready; + } + + /// + /// Waits until the manager is ready: a client connected and approved, or a server or host listening with + /// at least remote clients. + /// + internal static Task WaitReadyAsync(string managerFilter, int minClients, float timeoutSeconds) + { + ValidateTimeout(timeoutSeconds); + if (minClients < 0) + { + throw new ArgumentException("min_clients must be 0 or more."); + } + if (!EditorApplication.isPlaying) + { + throw new InvalidOperationException(NotPlayingMessage); + } + return WaitReadyAsync(ResolveManager(managerFilter), timeoutSeconds, minClients); + } + + private static Task WaitReadyAsync(NetworkManager manager, float timeoutSeconds, int minClients) + { + var completion = new TaskCompletionSource(); + var start = EditorApplication.timeSinceStartup; + var deadline = start + timeoutSeconds; + + WaitReadyResult Build(bool ready, bool timedOut, string reason) => new WaitReadyResult + { + Ready = ready, + TimedOut = timedOut, + Reason = reason, + WaitedSeconds = EditorApplication.timeSinceStartup - start, + Status = manager != null ? DescribeManager(manager) : null, + }; + + void Tick() + { + try + { + if (!EditorApplication.isPlaying || manager == null) + { + EditorApplication.update -= Tick; + completion.TrySetResult(Build(false, false, "Play mode stopped.")); + return; + } + + var reason = ReadyBlocker(manager, minClients); + if (reason == null) + { + EditorApplication.update -= Tick; + completion.TrySetResult(Build(true, false, null)); + } + else if (EditorApplication.timeSinceStartup >= deadline) + { + EditorApplication.update -= Tick; + completion.TrySetResult(Build(false, true, reason)); + } + } + catch (Exception e) + { + EditorApplication.update -= Tick; + completion.TrySetException(e); + } + } + + EditorApplication.update += Tick; + return completion.Task; + } + + /// Why the manager isn't ready yet, or null when it is. + private static string ReadyBlocker(NetworkManager manager, int minClients) + { + if (manager.ShutdownInProgress) + { + return "Shutting down."; + } + if (!manager.IsListening) + { + return string.IsNullOrEmpty(manager.DisconnectReason) + ? "Not running. Start it with netcode.gameobjects.session start." + : $"Not running. Last disconnect reason: {manager.DisconnectReason}"; + } + if (manager.IsServer) + { + var remote = manager.ConnectedClientsIds.Count(id => id != manager.LocalClientId); + return remote >= minClients ? null : $"{remote} of {minClients} remote client(s) connected."; + } + if (!manager.IsConnectedClient) + { + return manager.IsApproved ? "Approved, synchronizing." : "Connecting."; + } + if (manager.DistributedAuthorityMode && minClients > 0) + { + var others = manager.ConnectedClientsIds.Count(id => id != manager.LocalClientId); + return others >= minClients ? null : $"{others} of {minClients} other client(s) in the session."; + } + return null; + } + + // --- Spawn / despawn / ownership --- + + internal static SpawnResult Spawn(string prefab, float[] position, long owner, bool playerObject, string managerFilter) + { + var manager = ResolveRunningManager(managerFilter); + var prefabAsset = ResolvePrefab(prefab); + if (prefabAsset.GetComponent() == null) + { + throw new ArgumentException($"{AssetDatabase.GetAssetPath(prefabAsset)} has no NetworkObject on its root. Add one with netcode.gameobjects make_prefab."); + } + if (position != null && position.Length != 3) + { + throw new ArgumentException("position must be three numbers, e.g. [0, 1, 0]."); + } + if (!manager.DistributedAuthorityMode && !manager.IsServer) + { + throw new InvalidOperationException($"{manager.name} is a client. With a client-server topology only the server or host spawns; run the command against the server's editor."); + } + if (manager.DistributedAuthorityMode && !manager.IsConnectedClient) + { + throw new InvalidOperationException($"{manager.name} hasn't finished connecting to the session, so it can't spawn yet. Wait for it with netcode.gameobjects.session wait_ready."); + } + + var result = new SpawnResult { Manager = manager.name, Prefab = AssetDatabase.GetAssetPath(prefabAsset) }; + var ownerId = manager.DistributedAuthorityMode ? manager.LocalClientId : owner < 0 ? NetworkManager.ServerClientId : (ulong)owner; + if (manager.DistributedAuthorityMode && owner >= 0 && (ulong)owner != manager.LocalClientId) + { + result.Notes.Add($"Distributed authority spawns are owned by the spawning client; owner {owner} is ignored. Transfer it afterwards with netcode.gameobjects change_owner."); + } + else if (owner >= 0 && !IsValidOwner(manager, ownerId)) + { + throw new ArgumentException($"No connected client {owner}. List them with netcode.gameobjects list_clients."); + } + + var spawnPosition = position != null ? new Vector3(position[0], position[1], position[2]) : prefabAsset.transform.position; + NetworkObject spawned; + using (var errors = new LogCapture()) + { + // Spawn errors are logged rather than thrown; report them as the command's error. + spawned = NetworkObject.InstantiateAndSpawn(prefabAsset, manager, ownerId, isPlayerObject: playerObject, position: spawnPosition, rotation: prefabAsset.transform.rotation); + if (spawned == null || !spawned.IsSpawned) + { + // NGO instantiates before it spawns, so a failed spawn can leave an instance behind. + if (spawned != null) + { + DestroyUnspawned(manager, spawned); + } + throw new InvalidOperationException($"Spawning {prefabAsset.name} failed{errors.Describe()} Is it registered? Check with netcode.gameobjects list_prefabs."); + } + } + result.Object = Describe(manager, spawned); + return result; + } + + // Destroys an instance that never spawned the way NGO destroys a despawned one: through its prefab handler if + // it has one, else directly. + private static void DestroyUnspawned(NetworkManager manager, NetworkObject instance) + { + if (manager.PrefabHandler.ContainsHandler(instance)) + { + manager.PrefabHandler.HandleNetworkPrefabDestroy(instance); + } + else + { + UnityEngine.Object.Destroy(instance.gameObject); + } + } + + internal static DespawnResult Despawn(long networkObjectId, bool destroy, string managerFilter) + { + var manager = ResolveRunningManager(managerFilter); + var networkObject = FindSpawnedObject(manager, networkObjectId); + if (!networkObject.HasAuthority) + { + throw new InvalidOperationException(manager.DistributedAuthorityMode + ? $"{networkObject.name} is owned by client {networkObject.OwnerClientId}; in distributed authority only its owner can despawn it. Run the command against that client's editor." + : $"{manager.name} is a client; only the server or host can despawn. Run the command against the server's editor."); + } + + var result = new DespawnResult { Manager = manager.name, NetworkObjectId = networkObjectId, Name = networkObject.name }; + using (var errors = new LogCapture()) + { + try + { + networkObject.Despawn(destroy); + } + catch (NotSupportedException e) + { + throw new InvalidOperationException($"{networkObject.name} can't be despawned without destroying it: {e.Message} Retry with destroy true."); + } + if (networkObject != null && networkObject.IsSpawned) + { + throw new InvalidOperationException($"Despawning {networkObject.name} failed{errors.Describe()}"); + } + } + result.Despawned = true; + result.Destroyed = destroy; + return result; + } + + internal static OwnershipChangeResult ChangeOwner(long networkObjectId, long newOwner, string managerFilter) + { + var manager = ResolveRunningManager(managerFilter); + var networkObject = FindSpawnedObject(manager, networkObjectId); + if (newOwner < 0) + { + throw new ArgumentException("owner must be a client id (0 or more). List them with netcode.gameobjects list_clients."); + } + if (!IsValidOwner(manager, (ulong)newOwner)) + { + throw new ArgumentException($"No connected client {newOwner}. List them with netcode.gameobjects list_clients."); + } + if (!manager.DistributedAuthorityMode && !manager.IsServer) + { + throw new InvalidOperationException($"{manager.name} is a client; with a client-server topology only the server or host changes ownership. Run the command against the server's editor."); + } + + var result = new OwnershipChangeResult + { + Manager = manager.name, + NetworkObjectId = networkObjectId, + Name = networkObject.name, + PreviousOwner = (long)networkObject.OwnerClientId, + Topology = manager.NetworkConfig.NetworkTopology.ToString(), + }; + if (networkObject.OwnerClientId == (ulong)newOwner) + { + result.Owner = newOwner; + result.Notes.Add($"Client {newOwner} already owns it."); + return result; + } + + using (var errors = new LogCapture()) + { + if (!manager.DistributedAuthorityMode && (ulong)newOwner == NetworkManager.ServerClientId) + { + networkObject.RemoveOwnership(); + } + else + { + networkObject.ChangeOwnership((ulong)newOwner); + } + + // The change applies immediately when allowed; refusals are only logged at the Developer log level. + if (networkObject.OwnerClientId != (ulong)newOwner) + { + throw new InvalidOperationException($"Ownership of {networkObject.name} didn't change{errors.Describe()}" + (manager.DistributedAuthorityMode + ? " It may be locked, need a request, not be transferable or be session-owner only; check its ownership flags with netcode.gameobjects get_object." + : "")); + } + } + + result.Owner = (long)networkObject.OwnerClientId; + return result; + } + + // --- Helpers --- + + /// A connected client, or with a client-server topology the server itself (a dedicated server isn't a client). + private static bool IsValidOwner(NetworkManager manager, ulong clientId) + { + return manager.ConnectedClientsIds.Contains(clientId) + || (!manager.DistributedAuthorityMode && clientId == NetworkManager.ServerClientId); + } + + private static void ValidateTimeout(float timeoutSeconds) + { + if (timeoutSeconds <= 0 || timeoutSeconds > 600) + { + throw new ArgumentException("timeout must be more than 0 and at most 600 seconds."); + } + } + + private static Task WaitUntilAsync(Func condition, float timeoutSeconds, string what) + { + var completion = new TaskCompletionSource(); + var deadline = EditorApplication.timeSinceStartup + timeoutSeconds; + + void Tick() + { + try + { + if (condition()) + { + EditorApplication.update -= Tick; + completion.TrySetResult(true); + } + else if (!EditorApplication.isPlaying) + { + EditorApplication.update -= Tick; + completion.TrySetException(new InvalidOperationException($"Play mode stopped while waiting for {what}.")); + } + else if (EditorApplication.timeSinceStartup >= deadline) + { + EditorApplication.update -= Tick; + completion.TrySetException(new TimeoutException($"Timed out waiting for {what}.")); + } + } + catch (Exception e) + { + EditorApplication.update -= Tick; + completion.TrySetException(e); + } + } + + EditorApplication.update += Tick; + return completion.Task; + } + + /// + /// Collects warnings and errors logged while it's open. Netcode for GameObjects reports most refused + /// operations through its log rather than exceptions, so commands use this to return the reason. + /// + private sealed class LogCapture : IDisposable + { + private readonly List m_Messages = new List(); + + internal LogCapture() + { + Application.logMessageReceived += OnLog; + } + + internal bool HasErrors { get; private set; } + + private void OnLog(string message, string stackTrace, LogType type) + { + if (type == LogType.Log) + { + return; + } + if (type != LogType.Warning) + { + HasErrors = true; + } + m_Messages.Add(message); + } + + /// ": message; message." for an error message, or "." when nothing was logged. + internal string Describe() => m_Messages.Count == 0 ? "." : ": " + string.Join("; ", m_Messages.Distinct()) + "."; + + public void Dispose() => Application.logMessageReceived -= OnLog; + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Session.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Session.cs.meta new file mode 100644 index 0000000000..0e4ab64cb2 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Session.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b495005158e55147a8b4332dc9b9212 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Simulator.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Simulator.cs new file mode 100644 index 0000000000..2308bf039b --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Simulator.cs @@ -0,0 +1,491 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Unity.Netcode.Transports.UTP; +using Unity.Networking.Transport; +using Unity.Networking.Transport.Utilities; +using UnityEditor; +using UnityEngine; +#if MULTIPLAYER_TOOLS_NETWORK_SIMULATOR +using Unity.Multiplayer.Tools.NetworkSimulator.Runtime; +#endif + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + /// + /// The CLI network simulator: packet delay, jitter and loss applied to every UnityTransport driver in the + /// editor through Unity Transport's simulator pipeline stage (compiled into the editor and development builds), + /// the same mechanism Multiplayer Tools' Network Simulator uses. Settings last for the editor session and are + /// applied to drivers created later, so they can be set before entering play mode. + /// + /// + /// No driver is touched until the CLI turns the simulator, a timeout or a lag spike on. Each driver's own + /// simulator parameters (from the transport or a Network Simulator component) are saved before the first + /// override and put back once all three are off. + /// + [InitializeOnLoad] + internal static class NgoCliSimulator + { + private const string k_Prefix = "Unity.Netcode.GameObjects.Cli.Simulator."; + internal const string CustomPreset = "Custom"; + + internal static bool Enabled + { + get => SessionState.GetBool(k_Prefix + "Enabled", false); + set => SessionState.SetBool(k_Prefix + "Enabled", value); + } + + internal static string Preset + { + get => SessionState.GetString(k_Prefix + "Preset", "None"); + set => SessionState.SetString(k_Prefix + "Preset", value); + } + + internal static int DelayMs + { + get => SessionState.GetInt(k_Prefix + "DelayMs", 0); + set => SessionState.SetInt(k_Prefix + "DelayMs", value); + } + + internal static int JitterMs + { + get => SessionState.GetInt(k_Prefix + "JitterMs", 0); + set => SessionState.SetInt(k_Prefix + "JitterMs", value); + } + + internal static int DropPercent + { + get => SessionState.GetInt(k_Prefix + "DropPercent", 0); + set => SessionState.SetInt(k_Prefix + "DropPercent", value); + } + + /// True while every packet is dropped until the timeout is turned off. + internal static bool TimeoutActive { get; private set; } + private static double s_LagSpikeEnd; + + internal static bool LagSpikeActive => s_LagSpikeEnd > 0; + + internal static int LagSpikeMsLeft => s_LagSpikeEnd > 0 ? Math.Max(0, (int)((s_LagSpikeEnd - EditorApplication.timeSinceStartup) * 1000)) : 0; + + /// True while every packet is dropped (timeout or lag spike). + internal static bool Blackout => TimeoutActive || LagSpikeActive; + + private static bool Active => Enabled || Blackout; + + /// A driver's own simulator parameters, saved before the CLI first overrides them. + private struct Baseline + { + public SimulatorUtility.Parameters Stage; + public NetworkSimulatorParameter Global; + } + + // Keyed by the id UnityTransport passes to TransportInitialized (see TransportId). + private static readonly Dictionary s_Baselines = new Dictionary(); + + static NgoCliSimulator() + { + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + // UnityTransport clears its driver events when play mode starts, so subscribe once the runtime is up. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + private static void SubscribeToDrivers() + { + UnityTransport.TransportInitialized -= OnTransportInitialized; + UnityTransport.TransportInitialized += OnTransportInitialized; + } + + private static void OnPlayModeStateChanged(PlayModeStateChange change) + { + if (change == PlayModeStateChange.EnteredPlayMode && Active) + { + // Drivers created before the subscription (a NetworkManager started in Awake) still get the settings. + ApplyToLiveTransports(); + } + else if (change == PlayModeStateChange.ExitingPlayMode) + { + TimeoutActive = false; + s_LagSpikeEnd = 0; + EditorApplication.update -= EndLagSpikeWhenDue; + s_Baselines.Clear(); + } + } + + private static void OnTransportInitialized(int transportId, NetworkDriver driver) + { + // A new driver starts from its transport's own settings. + s_Baselines.Remove(transportId); + Apply(transportId, driver); + } + + /// The UnityTransports in the open scenes whose driver is running. + internal static List LiveTransports() + { + var transports = new List(); + if (!EditorApplication.isPlaying) + { + return transports; + } + foreach (var manager in NgoCliQueries.FindManagers()) + { + if (manager.NetworkConfig?.NetworkTransport is UnityTransport utp && utp.GetNetworkDriver().IsCreated && !transports.Contains(utp)) + { + transports.Add(utp); + } + } + return transports; + } + + /// + /// The id UnityTransport passes to TransportInitialized and TransportDisposed for this transport. Mirrors + /// UnityTransport's own computation; keep the two in step. + /// + private static int TransportId(UnityTransport utp) + { +#if UNITY_6000_2_OR_NEWER + return utp.GetEntityId().GetHashCode(); +#else + return utp.GetInstanceID(); +#endif + } + + /// Pushes the current settings to every running driver and returns the transports updated. + internal static List ApplyToLiveTransports(List live = null, bool clearBlackout = false) + { + var applied = new List(); + foreach (var utp in live ?? LiveTransports()) + { + if (Apply(TransportId(utp), utp.GetNetworkDriver(), clearBlackout)) + { + applied.Add(NgoCliQueries.GetHierarchyPath(utp.transform)); + } + } + return applied; + } + + /// + /// Overrides the driver's simulator parameters while the CLI simulator, a timeout or a lag spike is on, + /// and restores the saved ones once none is. Returns false when the driver was left alone. + /// + /// + /// With nothing saved and nothing on, still lift a blackout (100% loss both ways) left on the driver, e.g. + /// after a script recompile in play mode reset the saved state. + /// + private static bool Apply(int transportId, NetworkDriver driver, bool clearBlackout = false) + { + if (!driver.IsCreated || !driver.CurrentSettings.TryGet(out var stage)) + { + return false; + } + if (!s_Baselines.TryGetValue(transportId, out var baseline)) + { + if (!Active) + { + return clearBlackout && ClearLeftoverBlackout(driver); + } + driver.CurrentSettings.TryGet(out var global); + baseline = new Baseline { Stage = stage, Global = global }; + s_Baselines[transportId] = baseline; + } + + stage = baseline.Stage; + if (Enabled) + { + stage.PacketDelayMs = DelayMs; + stage.PacketJitterMs = JitterMs; + stage.PacketDropPercentage = DropPercent; + stage.PacketDropInterval = 0; + } + driver.ModifySimulatorStageParameters(stage); + + var parameters = baseline.Global; + if (Blackout) + { + parameters.ReceivePacketLossPercent = 100f; + parameters.SendPacketLossPercent = 100f; + } + driver.ModifyNetworkSimulatorParameters(parameters); + if (!Active) + { + // Restored the driver's own settings. + s_Baselines.Remove(transportId); + } + return true; + } + + private static bool ClearLeftoverBlackout(NetworkDriver driver) + { + if (!driver.CurrentSettings.TryGet(out var global) + || global.ReceivePacketLossPercent < 100f || global.SendPacketLossPercent < 100f) + { + return false; + } + global.ReceivePacketLossPercent = 0f; + global.SendPacketLossPercent = 0f; + driver.ModifyNetworkSimulatorParameters(global); + return true; + } + + internal static void SetTimeout(bool on) => TimeoutActive = on; + + internal static void StartLagSpike(int durationMs) + { + s_LagSpikeEnd = EditorApplication.timeSinceStartup + durationMs / 1000.0; + EditorApplication.update -= EndLagSpikeWhenDue; + EditorApplication.update += EndLagSpikeWhenDue; + } + + private static void EndLagSpikeWhenDue() + { + if (EditorApplication.timeSinceStartup < s_LagSpikeEnd) + { + return; + } + EditorApplication.update -= EndLagSpikeWhenDue; + s_LagSpikeEnd = 0; + // A timeout that's on keeps dropping packets. + ApplyToLiveTransports(clearBlackout: true); + } + + internal static SimulatorSettings Snapshot() => new SimulatorSettings + { + Enabled = Enabled, + Preset = Preset, + PacketDelayMs = DelayMs, + PacketJitterMs = JitterMs, + PacketDropPercent = DropPercent, + TimeoutActive = TimeoutActive, + LagSpikeMsLeft = LagSpikeMsLeft, + }; + } + + internal static partial class NgoCliQueries + { + internal static SimulatorStatusResult GetSimulator() + { + var result = new SimulatorStatusResult + { + Settings = NgoCliSimulator.Snapshot(), + MultiplayerToolsInstalled = MultiplayerToolsInstalled, + NetworkSimulatorAvailable = NetworkSimulatorAvailable, + LiveTransports = NgoCliSimulator.LiveTransports().Select(t => GetHierarchyPath(t.transform)).ToList(), + AppliesWhen = EditorApplication.isPlaying + ? "Now, to the live transports listed, and to transports started later in this editor session." + : "When a transport starts in play mode, for the rest of this editor session.", + }; +#if MULTIPLAYER_TOOLS_NETWORK_SIMULATOR + foreach (var component in FindObjects.ByType(includeInactive: false)) + { + result.NetworkSimulatorComponents.Add($"{component.gameObject.scene.name}/{GetHierarchyPath(component.transform)}"); + } +#endif + return result; + } + + /// + /// Sets the simulated network conditions: a preset and/or packet delay, jitter and loss (values win over + /// the preset; -1 leaves a value unchanged). Applies live to running transports and to transports started + /// later in this editor session. + /// + internal static SimulatorResult SetSimulator(bool enable, string preset, int delayMs, int jitterMs, int dropPercent) + { + if (delayMs > 2000) + { + throw new ArgumentException("delay_ms must be at most 2000 (-1 leaves it unchanged)."); + } + if (jitterMs > 200) + { + throw new ArgumentException("jitter_ms must be at most 200 (-1 leaves it unchanged)."); + } + if (dropPercent > 100) + { + throw new ArgumentException("drop_percent must be at most 100 (-1 leaves it unchanged)."); + } + + SimulatorPresetInfo chosen = null; + if (!string.IsNullOrWhiteSpace(preset)) + { + var presets = ListSimulatorPresets().Presets; + if (presets.Count == 0) + { + throw new ArgumentException($"{PresetsUnavailableReason} Pass delay_ms, jitter_ms and drop_percent instead."); + } + chosen = MatchPreset(presets, preset.Trim()) ?? throw new ArgumentException($"Unknown preset '{preset}'. Presets: {string.Join(", ", presets.Select(p => p.Name))}."); + } + + var result = new SimulatorResult { Previous = NgoCliSimulator.Snapshot() }; + NgoCliSimulator.Enabled = enable; + if (chosen != null) + { + NgoCliSimulator.Preset = chosen.Name; + NgoCliSimulator.DelayMs = chosen.PacketDelayMs; + NgoCliSimulator.JitterMs = chosen.PacketJitterMs; + NgoCliSimulator.DropPercent = chosen.PacketLossPercent; + if (chosen.PacketLossInterval > 0) + { + result.Notes.Add($"The preset also drops every {chosen.PacketLossInterval}th packet; the CLI simulator applies its loss percentage only."); + } + } + if (delayMs >= 0 || jitterMs >= 0 || dropPercent >= 0) + { + if (delayMs >= 0) + { + NgoCliSimulator.DelayMs = delayMs; + } + if (jitterMs >= 0) + { + NgoCliSimulator.JitterMs = jitterMs; + } + if (dropPercent >= 0) + { + NgoCliSimulator.DropPercent = dropPercent; + } + NgoCliSimulator.Preset = NgoCliSimulator.CustomPreset; + } + + var live = NgoCliSimulator.LiveTransports(); + result.AppliedLiveTo = NgoCliSimulator.ApplyToLiveTransports(live); + result.Current = NgoCliSimulator.Snapshot(); + result.AppliesWhen = result.AppliedLiveTo.Count > 0 + ? "Now, for the listed transports, and to transports started later in this editor session." + : "When a transport starts in play mode, for the rest of this editor session."; + if (enable && result.AppliedLiveTo.Count == 0 && live.Count > 0) + { + result.Notes.Add("The running transports have no simulator stage (it's compiled into the editor and development builds only)."); + } +#if MULTIPLAYER_TOOLS_NETWORK_SIMULATOR + if (FindObjects.ByType(includeInactive: false).Length > 0) + { + result.Notes.Add("A Multiplayer Tools Network Simulator component is in the scene; it reapplies its own preset when that preset changes."); + } +#endif + return result; + } + + /// + /// Matches a preset by its full name, its name without the bracketed details ("Mobile 4G" for + /// "Mobile 4G [4G, LTE, '13]"), or a prefix that only one preset has. + /// + private static SimulatorPresetInfo MatchPreset(List presets, string name) + { + static string ShortName(string full) + { + var bracket = full.IndexOf(" [", StringComparison.Ordinal); + return bracket > 0 ? full.Substring(0, bracket) : full; + } + + var exact = presets.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? presets.FirstOrDefault(p => string.Equals(ShortName(p.Name), name, StringComparison.OrdinalIgnoreCase)); + if (exact != null) + { + return exact; + } + var prefixed = presets.Where(p => p.Name.StartsWith(name, StringComparison.OrdinalIgnoreCase)).ToList(); + return prefixed.Count == 1 ? prefixed[0] : null; + } + + internal static SimulatorPresetListResult ListSimulatorPresets() + { +#if MULTIPLAYER_TOOLS_NETWORK_SIMULATOR + var result = new SimulatorPresetListResult { Source = "com.unity.multiplayer.tools" }; + foreach (var preset in NetworkSimulatorPresets.Values) + { + result.Presets.Add(new SimulatorPresetInfo + { + Name = preset.Name, + Description = preset.Description, + PacketDelayMs = preset.PacketDelayMs, + PacketJitterMs = preset.PacketJitterMs, + PacketLossPercent = preset.PacketLossPercent, + PacketLossInterval = preset.PacketLossInterval, + }); + } + return result; +#else + return new SimulatorPresetListResult + { + Source = null, + Note = $"{PresetsUnavailableReason} Without them, set delay_ms, jitter_ms and drop_percent directly.", + }; +#endif + } + + /// + /// Drops every packet for , then restores the simulator settings. A timeout + /// that's on keeps dropping packets after the spike. + /// + internal static SimulatorResult StartLagSpike(int durationMs) + { + if (durationMs < 10 || durationMs > 120_000) + { + throw new ArgumentException("duration_ms must be between 10 and 120000."); + } + RequireLiveTransports("simulate a lag spike"); + var result = new SimulatorResult { Previous = NgoCliSimulator.Snapshot() }; + NgoCliSimulator.StartLagSpike(durationMs); + result.AppliedLiveTo = NgoCliSimulator.ApplyToLiveTransports(); + result.Current = NgoCliSimulator.Snapshot(); + result.AppliesWhen = $"Now, for {durationMs} ms. Connections can time out if the spike outlasts the transport's disconnect timeout."; + return result; + } + + /// Drops every packet until turned off, so connections time out. + internal static SimulatorResult SetTimeout(bool enable) + { + if (enable) + { + RequireLiveTransports("simulate a timeout"); + } + var result = new SimulatorResult { Previous = NgoCliSimulator.Snapshot() }; + NgoCliSimulator.SetTimeout(enable); + result.AppliedLiveTo = NgoCliSimulator.ApplyToLiveTransports(clearBlackout: !enable); + result.Current = NgoCliSimulator.Snapshot(); + result.AppliesWhen = enable + ? "Now. Every packet is dropped until netcode.gameobjects.simulator timeout false; connections time out after the transport's disconnect timeout." + : "Now. Packets flow again, subject to the simulator settings."; + return result; + } + + private static void RequireLiveTransports(string action) + { + if (!EditorApplication.isPlaying) + { + throw new InvalidOperationException($"Can only {action} in play mode, with a session running. Enter play mode (editor.playmode start) and start one (netcode.gameobjects.session start)."); + } + if (NgoCliSimulator.LiveTransports().Count == 0) + { + throw new InvalidOperationException($"No running UnityTransport to {action} on. Start a session with netcode.gameobjects.session start."); + } + } + + /// True when Multiplayer Tools has the Network Simulator and its presets (2.0 or later). + internal static bool NetworkSimulatorAvailable + { + get + { +#if MULTIPLAYER_TOOLS_NETWORK_SIMULATOR + return true; +#else + return false; +#endif + } + } + + private static string PresetsUnavailableReason => MultiplayerToolsInstalled + ? "Presets come from the Network Simulator in Multiplayer Tools 2.0 or later; the installed version is older (upgrade com.unity.multiplayer.tools)." + : "Presets come from Multiplayer Tools 2.0 or later, which isn't installed (package add com.unity.multiplayer.tools)."; + + internal static bool MultiplayerToolsInstalled + { + get + { +#if MULTIPLAYER_TOOLS + return true; +#else + return false; +#endif + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Simulator.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Simulator.cs.meta new file mode 100644 index 0000000000..4733737c21 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Simulator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ac44a31e929df54b91848949ba8287a \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Stats.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Stats.cs new file mode 100644 index 0000000000..06a5674fe0 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Stats.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + internal static partial class NgoCliQueries + { + /// + /// Samples the manager's network traffic for frames: the bytes and messages it sends + /// and receives, per message type, read from its message manager as Netcode for GameObjects' own metrics + /// are. Adds the manager's clients with their round trip times. Doesn't block the main thread. + /// + internal static Task SampleStatsAsync(string managerFilter, int frames, float timeoutSeconds) + { + if (frames < 1 || frames > 10_000) + { + throw new ArgumentException("frames must be between 1 and 10000."); + } + ValidateTimeout(timeoutSeconds); + var manager = ResolveRunningManager(managerFilter); + if (EditorApplication.isPaused) + { + throw new InvalidOperationException("Play mode is paused, so no frames run. Unpause (editor.playmode pause) and retry."); + } + + var messageManager = manager.MessageManager; + var traffic = new TrafficHooks(); + messageManager.Hook(traffic); + var completion = new TaskCompletionSource(); + var startFrame = Time.frameCount; + var startTime = EditorApplication.timeSinceStartup; + var deadline = startTime + timeoutSeconds; + + void Stop() + { + EditorApplication.update -= Tick; + // After a shutdown the hooked message manager is already disposed, and its hooks with it. + if (manager != null && manager.MessageManager == messageManager) + { + messageManager.Unhook(traffic); + } + } + + void Tick() + { + if (!EditorApplication.isPlaying || manager == null) + { + Stop(); + completion.TrySetException(new InvalidOperationException("Play mode stopped while sampling.")); + return; + } + // A shutdown disposes the message manager and a restart creates a new one, so the hooks stop counting. + if (!manager.IsListening || manager.MessageManager != messageManager) + { + Stop(); + completion.TrySetException(new InvalidOperationException($"{manager.name}'s session ended while sampling, so the traffic counts are incomplete. Run get_stats again once it's running.")); + return; + } + if (Time.frameCount - startFrame < frames && EditorApplication.timeSinceStartup < deadline) + { + return; + } + Stop(); + try + { + completion.TrySetResult(BuildStats(manager, traffic, Time.frameCount - startFrame, EditorApplication.timeSinceStartup - startTime)); + } + catch (Exception e) + { + completion.TrySetException(e); + } + } + + EditorApplication.update += Tick; + return completion.Task; + } + + private static StatsResult BuildStats(NetworkManager manager, TrafficHooks traffic, int frames, double seconds) + { + var result = new StatsResult + { + Manager = manager.name, + Role = GetRole(manager), + Frames = frames, + Seconds = Math.Round(seconds, 3), + BytesSent = traffic.BytesSent, + BytesReceived = traffic.BytesReceived, + BytesSentPerSecond = seconds > 0 ? Math.Round(traffic.BytesSent / seconds, 1) : 0, + BytesReceivedPerSecond = seconds > 0 ? Math.Round(traffic.BytesReceived / seconds, 1) : 0, + MessagesSent = traffic.Messages.Values.Sum(m => m.Sent), + MessagesReceived = traffic.Messages.Values.Sum(m => m.Received), + Messages = traffic.Messages.Values + .OrderByDescending(m => m.BytesSent + m.BytesReceived) + .ThenBy(m => m.Message, StringComparer.Ordinal) + .ToList(), + SpawnedObjectCount = manager.SpawnManager?.SpawnedObjects.Count, + }; + + if (manager.IsListening) + { + result.Clients = ListClients(manager).Clients; + } + if (result.MessagesSent == 0 && result.MessagesReceived == 0) + { + result.Note = "No messages were sent or received during the sample. A host on its own sends nothing over the network; connect a client (another editor or a Multiplayer Play Mode player)."; + } + return result; + } + + /// + /// Counts what the message manager sends and receives, from the same hooks that feed Netcode for GameObjects' + /// metrics: message sizes per type, and batch sizes for the bytes handed to and read from the transport. + /// + private sealed class TrafficHooks : INetworkHooks + { + public long BytesSent; + public long BytesReceived; + public readonly Dictionary Messages = new Dictionary(); + + private MessageStat For(Type type) + { + if (!Messages.TryGetValue(type, out var stat)) + { + stat = new MessageStat { Message = type.Name }; + Messages[type] = stat; + } + return stat; + } + + public void OnAfterSendMessage(ulong clientId, ref T message, NetworkDelivery delivery, int messageSizeBytes) where T : INetworkMessage + { + var stat = For(typeof(T)); + stat.Sent++; + stat.BytesSent += messageSizeBytes; + } + + public void OnBeforeReceiveMessage(ulong senderId, Type messageType, int messageSizeBytes) + { + var stat = For(messageType); + stat.Received++; + stat.BytesReceived += messageSizeBytes; + } + + public void OnAfterSendBatch(ulong clientId, int messageCount, int batchSizeInBytes, NetworkDelivery delivery) => BytesSent += batchSizeInBytes; + + public void OnAfterReceiveBatch(ulong senderId, int messageCount, int batchSizeInBytes) => BytesReceived += batchSizeInBytes; + + public void OnBeforeSendMessage(ulong clientId, ref T message, NetworkDelivery delivery) where T : INetworkMessage + { + } + + public void OnAfterReceiveMessage(ulong senderId, Type messageType, int messageSizeBytes) + { + } + + public void OnBeforeSendBatch(ulong clientId, int messageCount, int batchSizeInBytes, NetworkDelivery delivery) + { + } + + public void OnBeforeReceiveBatch(ulong senderId, int messageCount, int batchSizeInBytes) + { + } + + public bool OnVerifyCanSend(ulong destinationId, Type messageType, NetworkDelivery delivery) => true; + + public bool OnVerifyCanReceive(ulong senderId, Type messageType, FastBufferReader messageContent, ref NetworkContext context) => true; + + public void OnBeforeHandleMessage(ref T message, ref NetworkContext context) where T : INetworkMessage + { + } + + public void OnAfterHandleMessage(ref T message, ref NetworkContext context) where T : INetworkMessage + { + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Stats.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Stats.cs.meta new file mode 100644 index 0000000000..cf284a70a5 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.Stats.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 25d7576828a3c6649b2bb83208530deb \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.cs b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.cs new file mode 100644 index 0000000000..24242bbca6 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using Unity.Netcode.Transports.UTP; +using UnityEditor; +using UnityEngine; + +namespace Unity.Netcode.GameObjects.Editor.Cli +{ + /// + /// Read and write operations behind the Netcode for GameObjects CLI commands (group netcode.gameobjects). + /// The commands live in the Unity.Netcode.GameObjects.CLI.Editor assembly, which only compiles when + /// com.unity.pipeline is installed; keeping the logic here means the package's tests exercise it without the + /// pipeline. Errors are thrown as or + /// with a message that names the next step to take. + /// + internal static partial class NgoCliQueries + { + internal const string Group = "netcode.gameobjects"; + + internal const string NoManagerMessage = + "No NetworkManager in the open scenes. Create one with netcode.gameobjects create_network_manager, or open the scene that has it (scene open)."; + + internal const string NotPlayingMessage = + "Requires play mode. Enter it with editor.playmode start, then start the session with netcode.gameobjects.session start."; + + internal const string NotListeningMessage = + "The NetworkManager isn't running. Start it with netcode.gameobjects.session start (mode host, server or client)."; + + // --- Status --- + + internal static StatusResult GetStatus() + { + var result = new StatusResult { IsPlaying = EditorApplication.isPlaying }; + foreach (var manager in FindManagers()) + { + result.Managers.Add(DescribeManager(manager)); + } + + if (result.Managers.Count == 0) + { + result.Hint = NoManagerMessage; + } + else if (!result.IsPlaying) + { + result.Hint = "Not in play mode: only the configuration is reported. Enter play mode (editor.playmode start), then netcode.gameobjects.session start."; + } + else if (result.Managers.All(m => !m.IsListening)) + { + result.Hint = NotListeningMessage; + } + return result; + } + + internal static ManagerStatus DescribeManager(NetworkManager manager) + { + var config = manager.NetworkConfig; + var status = new ManagerStatus + { + Name = manager.name, + Path = GetHierarchyPath(manager.transform), + Scene = manager.gameObject.scene.name, + IsSingleton = NetworkManager.Singleton == manager, + TickRate = config?.TickRate ?? 0, + Topology = config?.NetworkTopology.ToString(), + LogLevel = manager.LogLevel.ToString(), + Transport = DescribeTransport(config?.NetworkTransport), + UnifiedNetcode = DescribeUnifiedNetcode(manager), + Role = "none", + }; + + if (!EditorApplication.isPlaying) + { + return status; + } + + status.IsListening = manager.IsListening; + status.ShuttingDown = manager.ShutdownInProgress; + status.DisconnectReason = string.IsNullOrEmpty(manager.DisconnectReason) ? null : manager.DisconnectReason; + if (!manager.IsListening) + { + return status; + } + + status.Role = GetRole(manager); + status.IsConnectedClient = manager.IsConnectedClient; + status.IsApproved = manager.IsApproved; + status.LocalClientId = (long)manager.LocalClientId; + status.DistributedAuthority = manager.DistributedAuthorityMode; + if (manager.DistributedAuthorityMode) + { + status.CurrentSessionOwner = (long)manager.CurrentSessionOwner; + status.IsSessionOwner = manager.LocalClient != null && manager.LocalClient.IsSessionOwner; + } + status.ConnectedClientCount = manager.ConnectedClientsIds?.Count; + status.SpawnedObjectCount = manager.SpawnManager?.SpawnedObjects.Count; + if (manager.NetworkTickSystem != null) + { + status.ServerTick = manager.ServerTime.Tick; + status.LocalTick = manager.LocalTime.Tick; + } + return status; + } + + private static TransportSummary DescribeTransport(NetworkTransport transport) + { + if (transport == null) + { + return null; + } + + var summary = new TransportSummary { Type = transport.GetType().Name }; + if (transport is UnityTransport utp) + { + summary.Protocol = utp.Protocol.ToString(); + summary.Address = utp.ConnectionData.Address; + summary.Port = utp.ConnectionData.Port; + summary.ListenAddress = string.IsNullOrEmpty(utp.ConnectionData.ServerListenAddress) ? null : utp.ConnectionData.ServerListenAddress; + } + return summary; + } + + private static UnifiedNetcodeSummary DescribeUnifiedNetcode(NetworkManager manager) + { +#if UNIFIED_NETCODE + var summary = new UnifiedNetcodeSummary { Available = true }; + // A session runs on a Netcode for Entities world only when its prefabs include hybrid (GhostObject) ones. + if (EditorApplication.isPlaying && manager.IsListening) + { + summary.HybridSession = manager.NetworkConfig?.Prefabs != null && manager.NetworkConfig.Prefabs.HasGhostPrefabs; + } + return summary; +#else + return new UnifiedNetcodeSummary { Available = false }; +#endif + } + + internal static string GetRole(NetworkManager manager) + { + if (!manager.IsListening) + { + return "none"; + } + if (manager.IsHost) + { + return "host"; + } + if (manager.IsServer) + { + return "server"; + } + return manager.IsClient ? "client" : "none"; + } + + // --- Clients --- + + internal static ClientListResult ListClients(string managerFilter) => ListClients(ResolveRunningManager(managerFilter)); + + private static ClientListResult ListClients(NetworkManager manager) + { + var result = new ClientListResult { Manager = manager.name, Role = GetRole(manager) }; + foreach (var clientId in manager.ConnectedClientsIds) + { + manager.ConnectedClients.TryGetValue(clientId, out var client); + var info = new ClientInfo + { + ClientId = (long)clientId, + IsLocal = clientId == manager.LocalClientId, + IsServer = clientId == NetworkManager.ServerClientId && !manager.DistributedAuthorityMode, + IsSessionOwner = manager.DistributedAuthorityMode && clientId == manager.CurrentSessionOwner, + RttMs = TryGetRtt(manager, clientId), + }; + if (client != null) + { + info.PlayerObjectId = client.PlayerObject != null ? (long)client.PlayerObject.NetworkObjectId : null; + info.OwnedObjectCount = manager.SpawnManager?.GetClientOwnedObjects(clientId).Length; + } + result.Clients.Add(info); + } + + if (!manager.IsServer) + { + result.Note = "Read on a client: player objects and owned objects only include what this client can see. Run the command on the server or host for the full picture."; + } + return result; + } + + private static long? TryGetRtt(NetworkManager manager, ulong clientId) + { + var transport = manager.NetworkConfig?.NetworkTransport; + if (transport == null || clientId == manager.LocalClientId) + { + return null; + } + // A client only knows its round trip to the server; the transport takes the server id there. + if (!manager.IsServer && clientId != NetworkManager.ServerClientId) + { + return null; + } + try + { + return (long)transport.GetCurrentRtt(manager.IsServer ? clientId : NetworkManager.ServerClientId); + } + catch (Exception) + { + return null; + } + } + + // --- Objects --- + + internal static ObjectListResult ListObjects(string managerFilter, string nameContains, long owner, int limit) + { + if (limit < 1) + { + throw new ArgumentException("limit must be at least 1."); + } + var manager = ResolveRunningManager(managerFilter); + var objects = manager.SpawnManager.SpawnedObjects.Values + .Where(o => o != null) + .Where(o => string.IsNullOrEmpty(nameContains) || o.name.IndexOf(nameContains, StringComparison.OrdinalIgnoreCase) >= 0) + .Where(o => owner < 0 || o.OwnerClientId == (ulong)owner) + .OrderBy(o => o.NetworkObjectId) + .ToList(); + + var result = new ObjectListResult + { + Manager = manager.name, + Role = GetRole(manager), + Total = objects.Count, + }; + foreach (var networkObject in objects.Take(limit)) + { + result.Objects.Add(Summarize(networkObject, new ObjectSummary())); + } + result.Returned = result.Objects.Count; + return result; + } + + internal static ObjectDetail GetObject(long networkObjectId, string managerFilter) + { + var manager = ResolveRunningManager(managerFilter); + var networkObject = FindSpawnedObject(manager, networkObjectId); + return Describe(manager, networkObject); + } + + internal static ObjectDetail Describe(NetworkManager manager, NetworkObject networkObject) + { + var detail = Summarize(networkObject, new ObjectDetail()); + detail.Manager = manager.name; + detail.Scene = networkObject.gameObject.scene.name; + detail.ActiveInHierarchy = networkObject.gameObject.activeInHierarchy; + detail.DestroyWithScene = networkObject.DestroyWithScene; + detail.DontDestroyWithOwner = networkObject.DontDestroyWithOwner; + detail.Ownership = DescribeOwnership(manager, networkObject); + foreach (var behaviour in networkObject.GetComponentsInChildren(true)) + { + // As NGO assigns them: a nested NetworkObject owns the behaviours below it. + if (behaviour.GetComponentInParent(true) == networkObject) + { + detail.Behaviours.Add(behaviour.GetType().Name); + } + } + + // Observers are only tracked by the authority that decides visibility. + if (manager.IsServer || manager.DistributedAuthorityMode) + { + detail.Observers = new List(); + var observers = networkObject.GetObservers(); + while (observers.MoveNext()) + { + detail.Observers.Add((long)observers.Current); + } + detail.Observers.Sort(); + } + return detail; + } + + private static T Summarize(NetworkObject networkObject, T summary) where T : ObjectSummary + { + var position = networkObject.transform.position; + summary.NetworkObjectId = (long)networkObject.NetworkObjectId; + summary.Name = networkObject.name; + summary.PrefabIdHash = networkObject.PrefabIdHash; + summary.OwnerClientId = (long)networkObject.OwnerClientId; + summary.IsOwner = networkObject.IsOwner; + summary.HasAuthority = networkObject.HasAuthority; + summary.IsPlayerObject = networkObject.IsPlayerObject; + summary.InScenePlaced = networkObject.InScenePlaced; + summary.Hybrid = IsHybrid(networkObject); + summary.Position = new[] { position.x, position.y, position.z }; + return summary; + } + + private static OwnershipInfo DescribeOwnership(NetworkManager manager, NetworkObject networkObject) + { + var info = new OwnershipInfo + { + Transferable = networkObject.IsOwnershipTransferable, + Distributable = networkObject.IsOwnershipDistributable, + SessionOwnerOnly = networkObject.IsOwnershipSessionOwner, + RequestRequired = networkObject.IsOwnershipRequestRequired, + Locked = networkObject.IsOwnershipLocked, + RequestInProgress = networkObject.IsRequestInProgress, + WhoCanChange = manager.DistributedAuthorityMode + ? "The current owner (or the session owner for session-owner objects), subject to the ownership flags." + : "The server or host.", + }; + foreach (NetworkObject.OwnershipStatus flag in Enum.GetValues(typeof(NetworkObject.OwnershipStatus))) + { + if (flag != NetworkObject.OwnershipStatus.None && flag != NetworkObject.OwnershipStatus.All && networkObject.Ownership.HasFlag(flag)) + { + info.Status.Add(flag.ToString()); + } + } + return info; + } + + private static bool IsHybrid(NetworkObject networkObject) + { +#if UNIFIED_NETCODE + return networkObject.HasGhost; +#else + return false; +#endif + } + + internal static NetworkObject FindSpawnedObject(NetworkManager manager, long networkObjectId) + { + if (networkObjectId < 0) + { + throw new ArgumentException("id must be a NetworkObjectId (0 or more). List them with netcode.gameobjects list_objects."); + } + if (!manager.SpawnManager.SpawnedObjects.TryGetValue((ulong)networkObjectId, out var networkObject) || networkObject == null) + { + throw new ArgumentException($"No spawned NetworkObject with id {networkObjectId} on {manager.name}. List them with netcode.gameobjects list_objects."); + } + return networkObject; + } + + // --- NetworkManager resolution --- + + /// The NetworkManagers in the open scenes, including DontDestroyOnLoad ones in play mode. + internal static List FindManagers() + { + return FindObjects.ByType(includeInactive: true) + .Where(m => m != null && !EditorUtility.IsPersistent(m)) + .OrderBy(m => m.name, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Picks the NetworkManager a command targets: the one named (or with that + /// hierarchy path), else the one whose hierarchy path contains it, else the only one, else the running singleton. + /// + internal static NetworkManager ResolveManager(string filter, List managers = null) + { + managers ??= FindManagers(); + if (managers.Count == 0) + { + throw new InvalidOperationException(NoManagerMessage); + } + + if (!string.IsNullOrWhiteSpace(filter)) + { + var trimmed = filter.Trim(); + var paths = managers.Select(m => (manager: m, path: GetHierarchyPath(m.transform))).ToList(); + // An exact name or hierarchy path wins over a partial match: "Server" picks Server, not ServerBrowser. + var matches = paths.Where(p => + string.Equals(p.manager.name, trimmed, StringComparison.OrdinalIgnoreCase) + || string.Equals(p.path, trimmed, StringComparison.OrdinalIgnoreCase)).Select(p => p.manager).ToList(); + if (matches.Count == 0) + { + matches = paths.Where(p => p.path.IndexOf(trimmed, StringComparison.OrdinalIgnoreCase) >= 0).Select(p => p.manager).ToList(); + } + if (matches.Count == 1) + { + return matches[0]; + } + if (matches.Count == 0) + { + throw new ArgumentException($"No NetworkManager matches '{filter}'. NetworkManagers: {string.Join(", ", managers.Select(m => GetHierarchyPath(m.transform)))}."); + } + managers = matches; + } + + if (managers.Count == 1) + { + return managers[0]; + } + var singleton = NetworkManager.Singleton; + if (singleton != null && managers.Contains(singleton)) + { + return singleton; + } + throw new ArgumentException($"Several NetworkManagers. Pass manager with one of: {string.Join(", ", managers.Select(m => GetHierarchyPath(m.transform)))}."); + } + + // A scene's name for messages; an unsaved scene has none. + internal static string SceneLabel(UnityEngine.SceneManagement.Scene scene) => string.IsNullOrEmpty(scene.name) ? "an unsaved scene" : scene.name; + + internal static NetworkManager ResolveRunningManager(string filter) + { + // Report a missing NetworkManager first: entering play mode wouldn't help. + var manager = ResolveManager(filter); + if (!EditorApplication.isPlaying) + { + throw new InvalidOperationException(NotPlayingMessage); + } + if (!manager.IsListening) + { + throw new InvalidOperationException(NotListeningMessage); + } + return manager; + } + + /// + /// Sets a UnityTransport's address, port and listen address. Unlike UnityTransport.SetConnectionData, this + /// keeps the protocol (Relay or direct) and the WebSocket path, and leaves an empty listen address empty. + /// + internal static void SetConnectionAddress(UnityTransport utp, string address, ushort port, string listenAddress) + { + var data = utp.ConnectionData; + data.Address = address; + data.Port = port; + data.ServerListenAddress = listenAddress ?? string.Empty; + utp.ConnectionData = data; + } + + /// + /// The listen address for a server that clients reach at : empty (loopback) for a + /// loopback address, otherwise every interface, so clients on other machines can connect. + /// + internal static string DefaultListenAddress(string address) + { + if (string.Equals(address, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + if (IPAddress.TryParse(address, out var ip)) + { + return IPAddress.IsLoopback(ip) ? string.Empty : ip.AddressFamily == AddressFamily.InterNetworkV6 ? "::" : "0.0.0.0"; + } + return "0.0.0.0"; + } + + /// Throws when the transport connects through Relay, which ignores the address and port. + internal static void RequireDirectConnection(UnityTransport utp, string setting) + { + if (utp.Protocol == UnityTransport.ProtocolType.RelayUnityTransport) + { + throw new InvalidOperationException($"{utp.name}'s UnityTransport connects through Relay, which ignores {setting}: the server comes from the Relay allocation (SetRelayServerData). Set the transport's protocol to UnityTransport to connect directly."); + } + } + + internal static string GetHierarchyPath(Transform transform) + { + var path = transform.name; + for (var parent = transform.parent; parent != null; parent = parent.parent) + { + path = parent.name + "/" + path; + } + return path; + } + + internal static void RequireDefined(T value, string argument) where T : struct, Enum + { + if (!Enum.IsDefined(typeof(T), value)) + { + throw new ArgumentException($"{argument} must be one of: {string.Join(", ", Enum.GetNames(typeof(T)))}."); + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.cs.meta b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.cs.meta new file mode 100644 index 0000000000..cf8b3c838e --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/CliQueries/NgoCliQueries.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9da8038c4c457764aa3cc72674824130 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/Unity.Netcode.GameObjects.Editor.asmdef b/com.unity.netcode.gameobjects/Editor/Unity.Netcode.GameObjects.Editor.asmdef index b402c5badc..2bc4276b17 100644 --- a/com.unity.netcode.gameobjects/Editor/Unity.Netcode.GameObjects.Editor.asmdef +++ b/com.unity.netcode.gameobjects/Editor/Unity.Netcode.GameObjects.Editor.asmdef @@ -8,6 +8,7 @@ "Unity.Networking.Transport", "Unity.Services.Core", "Unity.Services.Authentication", + "Unity.Multiplayer.Tools.NetworkSimulator.Runtime", "GUID:953adc2a6b8b4e3c8df5b728bcd546e9", "GUID:eb8cf780bf058694ebf7ec5cf5b8cfa3" ], @@ -31,6 +32,11 @@ "expression": "", "define": "MULTIPLAYER_TOOLS" }, + { + "name": "com.unity.multiplayer.tools", + "expression": "2.0.0", + "define": "MULTIPLAYER_TOOLS_NETWORK_SIMULATOR" + }, { "name": "com.unity.modules.animation", "expression": "", diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli.meta b/com.unity.netcode.gameobjects/Tests/Editor/Cli.meta new file mode 100644 index 0000000000..4eef715737 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 948043e64a0659243b8f58ddbc810a58 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands.meta b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands.meta new file mode 100644 index 0000000000..621d2efc65 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7eac8c8a5af3ab4488e7056723c29a67 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/NetcodeGameObjectsCliCommandsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/NetcodeGameObjectsCliCommandsTests.cs new file mode 100644 index 0000000000..3503b8f6c3 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/NetcodeGameObjectsCliCommandsTests.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using Unity.Netcode.GameObjects.Editor.Cli; +using Unity.Pipeline.Commands; +using UnityEditor.SceneManagement; +using UnityEngine.TestTools; + +namespace Unity.Netcode.GameObjects.EditorTests +{ + /// + /// Covers the netcode.gameobjects [CliGroupCommand] wrappers as the pipeline sees them: what is registered (the wire + /// API) and what an invocation returns. The logic behind them is covered by NgoCliQueriesTests. Like the + /// commands, this assembly only compiles when com.unity.pipeline is installed. + /// + internal class NetcodeGameObjectsCliCommandsTests + { + private const string k_Package = "Unity.Netcode.GameObjects.CLI.Editor"; + private const string k_Tag = "networking/netcode"; + + // Parameters are wire API: clients bind positionals to required parameters in order, so renaming, + // reordering or newly requiring one breaks them. A trailing '*' marks a required parameter. + // Changing this table means changing the API; add new parameters at the end, as optional. + private static readonly Dictionary k_WireApi = new Dictionary + { + ["netcode.gameobjects status"] = new string[0], + ["netcode.gameobjects list_clients"] = new[] { "manager" }, + ["netcode.gameobjects list_objects"] = new[] { "name_contains", "owner", "limit", "manager" }, + ["netcode.gameobjects get_object"] = new[] { "id*", "manager" }, + ["netcode.gameobjects spawn"] = new[] { "prefab*", "position", "owner", "player_object", "manager" }, + ["netcode.gameobjects despawn"] = new[] { "id*", "destroy", "manager" }, + ["netcode.gameobjects change_owner"] = new[] { "id*", "owner*", "manager" }, + ["netcode.gameobjects create_network_manager"] = new[] { "name", "topology", "address", "port", "dry_run" }, + ["netcode.gameobjects list_prefabs"] = new[] { "list" }, + ["netcode.gameobjects register_prefab"] = new[] { "prefab*", "list", "dry_run" }, + ["netcode.gameobjects make_prefab"] = new[] { "prefab*", "dry_run" }, + ["netcode.gameobjects audit"] = new[] { "scope", "folder", "limit", "manager" }, + ["netcode.gameobjects.profiler get_stats"] = new[] { "frames", "manager", "timeout" }, + ["netcode.gameobjects.session start"] = new[] { "mode*", "address", "port", "manager" }, + ["netcode.gameobjects.session stop"] = new[] { "discard_message_queue", "manager" }, + ["netcode.gameobjects.session kick"] = new[] { "client_id*", "reason", "manager" }, + ["netcode.gameobjects.session reconnect"] = new[] { "manager", "timeout" }, + ["netcode.gameobjects.session wait_ready"] = new[] { "min_clients", "manager", "timeout" }, + ["netcode.gameobjects.simulator get"] = new string[0], + ["netcode.gameobjects.simulator set"] = new[] { "enable", "preset", "delay_ms", "jitter_ms", "drop_percent" }, + ["netcode.gameobjects.simulator list_presets"] = new string[0], + ["netcode.gameobjects.simulator lag_spike"] = new[] { "duration_ms" }, + ["netcode.gameobjects.simulator timeout"] = new[] { "enable" }, + ["netcode.gameobjects.config get"] = new[] { "manager" }, + ["netcode.gameobjects.config set"] = new[] { "field*", "value*", "manager", "dry_run" }, + }; + + private static readonly string[] k_Groups = + { + "netcode.gameobjects", "netcode.gameobjects.session", "netcode.gameobjects.simulator", + "netcode.gameobjects.config", "netcode.gameobjects.profiler", + }; + + [OneTimeSetUp] + public void WarmCommandRegistry() + { + // Discovery logs an error for each invalid command in other packages (e.g. one written for an older + // pipeline), which would fail whichever test discovers first. Discover once, ignoring those; the + // registry caches the result, and the tests check only these commands. + LogAssert.ignoreFailingMessages = true; + try + { + CommandRegistry.DiscoverCommands(); + } + finally + { + LogAssert.ignoreFailingMessages = false; + } + } + + [SetUp] + public void SetUp() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + private static List Commands() => + CommandRegistry.DiscoverCommands().Where(c => c.Package == k_Package).ToList(); + + private static CommandInfo Command(string name) + { + var command = Commands().SingleOrDefault(c => c.FullName == name); + Assert.IsNotNull(command, $"{name} is not registered."); + return command; + } + + /// + /// Invokes a command's method the way the server does after binding: named arguments by CLI name, every + /// other parameter at its C# default. Unwraps the reflection exception so tests see the command's own. + /// + private static object Invoke(string name, params (string Name, object Value)[] arguments) + { + var command = Command(name); + var methodParameters = command.Method.GetParameters(); + var values = new object[methodParameters.Length]; + for (var i = 0; i < methodParameters.Length; i++) + { + var cliName = command.Parameters[i].Name; + var supplied = arguments.Where(a => a.Name == cliName).ToList(); + values[i] = supplied.Count > 0 ? supplied[0].Value : methodParameters[i].DefaultValue; + } + + object result; + try + { + result = command.Method.Invoke(command.Target, values); + } + catch (TargetInvocationException e) when (e.InnerException != null) + { + throw e.InnerException; + } + + Assert.IsNotInstanceOf(result, "This helper is for synchronous commands."); + return result; + } + + private static Dictionary InvokeObject(string name, params (string Name, object Value)[] arguments) + { + var result = Invoke(name, arguments); + Assert.IsInstanceOf>(result, $"{name} should return a camelCase object."); + return (Dictionary)result; + } + + [Test] + public void Registration_MatchesTheWireApi() + { + var commands = Commands(); + + CollectionAssert.AreEquivalent(k_WireApi.Keys, commands.Select(c => c.FullName), "Registered commands (group name)"); + foreach (var command in commands) + { + var actual = command.Parameters.Select(p => p.Required ? p.Name + "*" : p.Name).ToArray(); + CollectionAssert.AreEqual(k_WireApi[command.FullName], actual, $"{command.FullName} parameters (name, order, required)"); + } + } + + [Test] + public void Registration_EveryCommandIsTaggedAndRunsOnTheMainThread() + { + foreach (var command in Commands()) + { + CollectionAssert.Contains(command.Tags, k_Tag, command.FullName); + CollectionAssert.Contains(k_Groups, command.Group, command.FullName); + Assert.IsTrue(command.MainThreadRequired, $"{command.FullName} reads scene and network state, so it must run on the main thread."); + Assert.IsNotEmpty(command.Description, command.FullName); + } + } + + [Test] + public void Registration_TypedParametersKeepTheirTypes() + { + // Enum types let the pipeline's schema list the accepted values; a double timeout lets the server pass + // the time left of the request. + Assert.AreEqual(typeof(NgoCliQueries.SessionMode), Command("netcode.gameobjects.session start").Parameters.Single(p => p.Name == "mode").ParameterType); + Assert.AreEqual(typeof(NetworkTopologyTypes), Command("netcode.gameobjects create_network_manager").Parameters.Single(p => p.Name == "topology").ParameterType); + Assert.AreEqual(typeof(float[]), Command("netcode.gameobjects spawn").Parameters.Single(p => p.Name == "position").ParameterType); + foreach (var name in new[] { "netcode.gameobjects.profiler get_stats", "netcode.gameobjects.session reconnect", "netcode.gameobjects.session wait_ready" }) + { + Assert.AreEqual(typeof(double), Command(name).Parameters.Single(p => p.Name == "timeout").ParameterType, name); + } + } + + [Test] + public void Status_WithoutManager_ReturnsAHint() + { + var result = InvokeObject("netcode.gameobjects status"); + + Assert.AreEqual(false, result["isPlaying"]); + CollectionAssert.IsEmpty((List)result["managers"]); + StringAssert.Contains("create_network_manager", (string)result["hint"]); + } + + [Test] + public void CreateNetworkManager_ThenConfigGet_ReturnCamelCaseResults() + { + var created = InvokeObject("netcode.gameobjects create_network_manager", ("port", 7811)); + Assert.AreEqual(true, created["created"]); + Assert.AreEqual("ClientServer", created["topology"]); + + var config = InvokeObject("netcode.gameobjects.config get"); + var networkConfig = (Dictionary)config["networkConfig"]; + Assert.IsTrue(networkConfig.ContainsKey("tick_rate"), "NetworkConfig fields keep the snake_case names config set takes."); + Assert.AreEqual(7811, ((Dictionary)config["transport"])["port"]); + + var audited = InvokeObject("netcode.gameobjects audit", ("scope", "config")); + Assert.IsInstanceOf>(audited["issues"]); + Assert.AreEqual(1, audited["managersChecked"]); + Assert.IsNull(audited["prefabsScanned"], "Prefabs aren't checked in the config scope."); + Assert.Throws(() => Invoke("netcode.gameobjects audit", ("scope", "everything"))); + } + + [Test] + public void Errors_NameTheNextStep() + { + var noManager = Assert.Throws(() => Invoke("netcode.gameobjects list_clients")); + StringAssert.Contains("create_network_manager", noManager.Message); + + Invoke("netcode.gameobjects create_network_manager"); + var notPlaying = Assert.Throws(() => Invoke("netcode.gameobjects list_objects")); + StringAssert.Contains("editor.playmode start", notPlaying.Message); + + var unknownPrefab = Assert.Throws(() => Invoke("netcode.gameobjects make_prefab", ("prefab", "NoSuchPrefab"))); + StringAssert.Contains("list_prefabs", unknownPrefab.Message); + } + + [Test] + public void WaitSeconds_StopsBeforeTheRequestTimeout() + { + Assert.AreEqual(28f, NetcodeGameObjectsCliCommands.WaitSeconds(30), 0.001); + Assert.AreEqual(4.5f, NetcodeGameObjectsCliCommands.WaitSeconds(5), 0.001); + Assert.AreEqual(600f, NetcodeGameObjectsCliCommands.WaitSeconds(double.PositiveInfinity)); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/NetcodeGameObjectsCliCommandsTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/NetcodeGameObjectsCliCommandsTests.cs.meta new file mode 100644 index 0000000000..fbca869679 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/NetcodeGameObjectsCliCommandsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee672029a538da244816d0a87d41859c \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/Unity.Netcode.GameObjects.CLI.Editor.Tests.asmdef b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/Unity.Netcode.GameObjects.CLI.Editor.Tests.asmdef new file mode 100644 index 0000000000..7c6579a06d --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/Unity.Netcode.GameObjects.CLI.Editor.Tests.asmdef @@ -0,0 +1,36 @@ +{ + "name": "Unity.Netcode.GameObjects.CLI.Editor.Tests", + "rootNamespace": "Unity.Netcode.GameObjects.EditorTests", + "references": [ + "Unity.Pipeline", + "Unity.Pipeline.Attributes", + "Unity.Netcode.Runtime", + "Unity.Netcode.GameObjects.Editor", + "Unity.Netcode.GameObjects.CLI.Editor", + "Unity.Networking.Transport", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS", + "UNITY_PIPELINE_PACKAGE_AVAILABLE" + ], + "versionDefines": [ + { + "name": "com.unity.pipeline", + "expression": "0.9.0-exp.1", + "define": "UNITY_PIPELINE_PACKAGE_AVAILABLE" + } + ], + "noEngineReferences": false +} diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/Unity.Netcode.GameObjects.CLI.Editor.Tests.asmdef.meta b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/Unity.Netcode.GameObjects.CLI.Editor.Tests.asmdef.meta new file mode 100644 index 0000000000..f5343c0211 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli/Commands/Unity.Netcode.GameObjects.CLI.Editor.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d9f623866e3e4a84583fa7d913a8aff1 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli/NgoCliQueriesTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/Cli/NgoCliQueriesTests.cs new file mode 100644 index 0000000000..cd00454058 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli/NgoCliQueriesTests.cs @@ -0,0 +1,769 @@ +using System; +using System.Collections; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Unity.Netcode.GameObjects.Editor.Cli; +using Unity.Netcode.GameObjects.Editor.Configuration; +using Unity.Netcode.Transports.UTP; +using Unity.Networking.Transport.Utilities; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.GameObjects.EditorTests +{ + /// + /// Covers the query layer behind the netcode.gameobjects CLI commands. It lives in the editor assembly and + /// compiles without com.unity.pipeline, so these run in every test configuration. + /// + internal class NgoCliQueriesTests + { + private const string k_Folder = "Assets/NgoCliTests"; + private const string k_ListPath = k_Folder + "/CliTestPrefabs.asset"; + + private bool m_GenerateDefaultNetworkPrefabs; + private string m_DefaultNetworkPrefabsPath; + + [SetUp] + public void SetUp() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + var settings = NetcodeForGameObjectsProjectSettings.instance; + m_GenerateDefaultNetworkPrefabs = settings.GenerateDefaultNetworkPrefabs; + m_DefaultNetworkPrefabsPath = NetworkPrefabProcessor.DefaultNetworkPrefabsPath; + // Register prefabs explicitly in these tests, into a list of their own. + settings.GenerateDefaultNetworkPrefabs = false; + if (!AssetDatabase.IsValidFolder(k_Folder)) + { + AssetDatabase.CreateFolder("Assets", "NgoCliTests"); + } + NetworkPrefabProcessor.DefaultNetworkPrefabsPath = k_ListPath; + ResetSimulator(); + } + + [TearDown] + public void TearDown() + { + NetcodeForGameObjectsProjectSettings.instance.GenerateDefaultNetworkPrefabs = m_GenerateDefaultNetworkPrefabs; + NetworkPrefabProcessor.DefaultNetworkPrefabsPath = m_DefaultNetworkPrefabsPath; + AssetDatabase.DeleteAsset(k_Folder); + ResetSimulator(); + } + + private static void ResetSimulator() + { + NgoCliSimulator.Enabled = false; + NgoCliSimulator.Preset = "None"; + NgoCliSimulator.DelayMs = 0; + NgoCliSimulator.JitterMs = 0; + NgoCliSimulator.DropPercent = 0; + NgoCliSimulator.SetTimeout(false); + } + + private static GameObject CreatePrefab(string name, params Type[] components) + { + var gameObject = new GameObject(name, components); + var prefab = PrefabUtility.SaveAsPrefabAsset(gameObject, $"{k_Folder}/{name}.prefab"); + UnityEngine.Object.DestroyImmediate(gameObject); + return prefab; + } + + private static NetworkManager CreateManager(NetworkTopologyTypes topology = NetworkTopologyTypes.ClientServer, int port = 7777) + { + NgoCliQueries.CreateNetworkManager("NetworkManager", topology, "127.0.0.1", port, false); + return NgoCliQueries.FindManagers().Single(); + } + + // --- Status and resolution --- + + [Test] + public void Status_WithoutManager_NamesTheNextStep() + { + var status = NgoCliQueries.GetStatus(); + + Assert.IsFalse(status.IsPlaying); + CollectionAssert.IsEmpty(status.Managers); + StringAssert.Contains("create_network_manager", status.Hint); + } + + [Test] + public void Status_InEditMode_ReportsTheConfiguration() + { + CreateManager(port: 7801); + + var manager = NgoCliQueries.GetStatus().Managers.Single(); + + Assert.AreEqual("none", manager.Role); + Assert.IsFalse(manager.IsListening); + Assert.AreEqual("ClientServer", manager.Topology); + Assert.AreEqual(nameof(UnityTransport), manager.Transport.Type); + Assert.AreEqual(7801, manager.Transport.Port); + Assert.IsNull(manager.LocalClientId, "Runtime values are null outside play mode."); + } + + [Test] + public void RuntimeQueries_OutsidePlayMode_AskForPlayMode() + { + CreateManager(); + + var error = Assert.Throws(() => NgoCliQueries.ListClients(null)); + StringAssert.Contains("editor.playmode start", error.Message); + Assert.Throws(() => NgoCliQueries.ListObjects(null, null, -1, 10)); + Assert.Throws(() => NgoCliQueries.StartLagSpike(100)); + } + + [Test] + public void ResolveManager_PicksByNameOrAsksWhenAmbiguous() + { + Assert.Throws(() => NgoCliQueries.ResolveManager(null)); + + var first = new GameObject("ServerManager").AddComponent(); + var second = new GameObject("ClientManager").AddComponent(); + + Assert.AreSame(second, NgoCliQueries.ResolveManager("client")); + var ambiguous = Assert.Throws(() => NgoCliQueries.ResolveManager(null)); + StringAssert.Contains("ServerManager", ambiguous.Message); + Assert.Throws(() => NgoCliQueries.ResolveManager("Nope")); + Assert.AreSame(first, NgoCliQueries.ResolveManager("ServerManager")); + + var longer = new GameObject("ServerManager2").AddComponent(); + Assert.AreSame(first, NgoCliQueries.ResolveManager("ServerManager"), "An exact name wins over a partial match."); + Assert.AreSame(longer, NgoCliQueries.ResolveManager("ServerManager2")); + } + + // --- Authoring --- + + [Test] + public void CreateNetworkManager_SetsUpTransportAndTopology() + { + var dryRun = NgoCliQueries.CreateNetworkManager("Net", NetworkTopologyTypes.DistributedAuthority, "10.0.0.5", 9000, true); + Assert.IsFalse(dryRun.Created); + CollectionAssert.IsEmpty(NgoCliQueries.FindManagers(), "dry_run changes nothing."); + Assert.IsTrue(dryRun.Notes.Any(n => n.Contains("Distributed authority"))); + + var result = NgoCliQueries.CreateNetworkManager("Net", NetworkTopologyTypes.DistributedAuthority, "10.0.0.5", 9000, false); + Assert.IsTrue(result.Created); + + var manager = NgoCliQueries.FindManagers().Single(); + var transport = (UnityTransport)manager.NetworkConfig.NetworkTransport; + Assert.AreSame(manager.GetComponent(), transport); + Assert.AreEqual(NetworkTopologyTypes.DistributedAuthority, manager.NetworkConfig.NetworkTopology); + Assert.AreEqual("10.0.0.5", transport.ConnectionData.Address); + Assert.AreEqual(9000, transport.ConnectionData.Port); + Assert.AreEqual("0.0.0.0", transport.ConnectionData.ServerListenAddress, "A server for a remote address listens on every interface."); + Assert.AreEqual(string.Empty, NgoCliQueries.DefaultListenAddress("127.0.0.1")); + Assert.AreEqual(string.Empty, NgoCliQueries.DefaultListenAddress("localhost")); + Assert.AreEqual("::", NgoCliQueries.DefaultListenAddress("2001:db8::1")); + + var again = Assert.Throws(() => NgoCliQueries.CreateNetworkManager("Net", NetworkTopologyTypes.ClientServer, "127.0.0.1", 7777, false)); + StringAssert.Contains("already have a NetworkManager", again.Message); + StringAssert.Contains("in an unsaved scene", again.Message, "An unsaved scene has no name to show."); + } + + [Test] + public void MakePrefab_AddsANetworkObjectWithAPrefabId() + { + CreatePrefab("Plain"); + var path = $"{k_Folder}/Plain.prefab"; + + var dryRun = NgoCliQueries.MakePrefab(path, true); + Assert.IsFalse(dryRun.AddedNetworkObject); + Assert.IsNull(AssetDatabase.LoadAssetAtPath(path).GetComponent()); + + var result = NgoCliQueries.MakePrefab("Plain", false); + Assert.IsTrue(result.AddedNetworkObject); + var networkObject = AssetDatabase.LoadAssetAtPath(path).GetComponent(); + Assert.IsNotNull(networkObject); + if (result.PrefabIdHash.HasValue) + { + Assert.AreEqual(networkObject.GlobalObjectIdHash, (uint)result.PrefabIdHash.Value); + } + + Assert.IsTrue(NgoCliQueries.MakePrefab(path, false).AlreadyNetworkObject); + } + + [Test] + public void RegisterPrefab_AddsOnceAndListsIt() + { + var prefab = CreatePrefab("Networked", typeof(NetworkObject)); + var list = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(list, k_ListPath); + + var dryRun = NgoCliQueries.RegisterPrefab("Networked", k_ListPath, true); + Assert.IsFalse(dryRun.Registered); + Assert.IsFalse(list.Contains(prefab)); + + var result = NgoCliQueries.RegisterPrefab("Networked", k_ListPath, false); + Assert.IsTrue(result.Registered); + Assert.IsTrue(list.Contains(prefab)); + + var again = NgoCliQueries.RegisterPrefab($"{k_Folder}/Networked.prefab", null, false); + Assert.IsTrue(again.AlreadyRegistered, "With no NetworkManager, the default list (here the test list) is the target."); + + var listed = NgoCliQueries.ListPrefabs("CliTestPrefabs").Lists.Single(); + Assert.AreEqual(k_ListPath, listed.Path); + Assert.AreEqual($"{k_Folder}/Networked.prefab", listed.Prefabs.Single().Path); + } + + [Test] + public void RegisterPrefab_OverrideSourceCountsAsRegistered() + { + var source = CreatePrefab("Source", typeof(NetworkObject)); + var target = CreatePrefab("Target", typeof(NetworkObject)); + var list = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(list, k_ListPath); + // As in the inspector's override entries: Prefab differs from the source being overridden. + list.Add(new NetworkPrefab { Override = NetworkPrefabOverride.Prefab, Prefab = target, SourcePrefabToOverride = source, OverridingTargetPrefab = target }); + + var result = NgoCliQueries.RegisterPrefab("Source", k_ListPath, false); + + Assert.IsTrue(result.AlreadyRegistered); + Assert.IsFalse(result.Registered); + Assert.AreEqual(1, list.PrefabList.Count); + } + + [Test] + public void MakePrefab_WithChildNetworkObjects_ChangesNothing() + { + var parent = new GameObject("Parent"); + new GameObject("Child", typeof(NetworkObject)).transform.SetParent(parent.transform); + PrefabUtility.SaveAsPrefabAsset(parent, $"{k_Folder}/Parent.prefab"); + UnityEngine.Object.DestroyImmediate(parent); + + var error = Assert.Throws(() => NgoCliQueries.MakePrefab("Parent", false)); + + StringAssert.Contains("nested NetworkObjects", error.Message); + Assert.IsNull(AssetDatabase.LoadAssetAtPath($"{k_Folder}/Parent.prefab").GetComponent()); + } + + [Test] + public void AuditPrefabs_ReportsBrokenOverridesAndEmptyEntries() + { + var source = CreatePrefab("Source", typeof(NetworkObject)); + var plain = CreatePrefab("Plain"); + var list = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(list, k_ListPath); + list.Add(null); + list.Add(new NetworkPrefab { Override = NetworkPrefabOverride.Prefab, SourcePrefabToOverride = source }); + list.Add(new NetworkPrefab { Override = NetworkPrefabOverride.Prefab, SourcePrefabToOverride = source, OverridingTargetPrefab = plain }); + list.Add(new NetworkPrefab { Override = NetworkPrefabOverride.Hash, SourceHashToOverride = 0, OverridingTargetPrefab = source }); + + var result = NgoCliQueries.AuditPrefabs(k_Folder, 50); + + var entries = result.Issues.Where(i => i.Code == "LIST_ENTRY_INVALID" && i.Asset == k_ListPath).ToList(); + var found = string.Join(" | ", entries.Select(i => $"{i.Severity}: {i.Message}")); + Assert.AreEqual(4, entries.Count, found); + Assert.AreEqual(1, entries.Count(i => i.Severity == "warning"), "Netcode ignores an empty entry: " + found); + Assert.IsTrue(entries.Any(i => i.Message.Contains("no target prefab")), found); + Assert.IsTrue(entries.Any(i => i.Message.Contains("Plain.prefab has no NetworkObject")), found); + Assert.IsTrue(entries.Any(i => i.Message.Contains("source hash is 0")), found); + + var listed = NgoCliQueries.ListPrefabs("CliTestPrefabs").Lists.Single(); + Assert.AreEqual(4, listed.Prefabs.Count, "Listing doesn't stop at an empty entry."); + } + + [Test] + public void AuditPrefabs_CountsAHashOverrideAsRegistration() + { + var source = CreatePrefab("Source", typeof(NetworkObject)); + var target = CreatePrefab("Target", typeof(NetworkObject)); + var list = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(list, k_ListPath); + list.Add(new NetworkPrefab { Prefab = target }); + // The inspector keeps Prefab after an entry switches to Hash; NGO ignores it, and so does the audit. + var leftover = CreatePrefab("Leftover"); + list.Add(new NetworkPrefab { Override = NetworkPrefabOverride.Hash, SourceHashToOverride = source.GetComponent().GlobalObjectIdHash, OverridingTargetPrefab = target, Prefab = leftover }); + + var result = NgoCliQueries.AuditPrefabs(k_Folder, 50); + + var found = string.Join(", ", result.Issues.Select(i => $"{i.Code} {i.Asset}")); + Assert.IsFalse(result.Issues.Any(i => i.Code == "NOT_REGISTERED"), found); + Assert.IsFalse(result.Issues.Any(i => i.Code == "LIST_ENTRY_INVALID"), found); + Assert.IsTrue(NgoCliQueries.RegisterPrefab("Source", k_ListPath, true).AlreadyRegistered, "register_prefab and audit agree."); + } + + [Test] + public void AuditPrefabs_CountsThePlayerPrefabAsRegistration() + { + var player = CreatePrefab("Player", typeof(NetworkObject)); + var manager = CreateManager(); + manager.NetworkConfig.PlayerPrefab = player; + + var result = NgoCliQueries.AuditPrefabs(k_Folder, 50); + + Assert.IsFalse(result.Issues.Any(i => i.Code == "NOT_REGISTERED"), string.Join(", ", result.Issues.Select(i => $"{i.Code} {i.Asset}"))); + } + + [Test] + public void SetConfig_OnAPrefabInstance_RecordsAnOverride() + { + CreateManager(); + var managerPrefab = PrefabUtility.SaveAsPrefabAssetAndConnect(NgoCliQueries.FindManagers().Single().gameObject, $"{k_Folder}/Manager.prefab", InteractionMode.AutomatedAction); + Assert.IsNotNull(managerPrefab); + + NgoCliQueries.SetConfig("tick_rate", "60", null, false); + + var instance = NgoCliQueries.FindManagers().Single(); + Assert.IsTrue(PrefabUtility.GetPropertyModifications(instance).Any(m => m.target == managerPrefab.GetComponent() && m.propertyPath.EndsWith("TickRate") && m.value == "60"), + "The change is kept as a prefab override."); + } + + [Test] + public void RegisterPrefab_WithoutNetworkObject_PointsToMakePrefab() + { + CreatePrefab("Plain"); + + var error = Assert.Throws(() => NgoCliQueries.RegisterPrefab("Plain", null, false)); + StringAssert.Contains("make_prefab", error.Message); + Assert.Throws(() => NgoCliQueries.RegisterPrefab("NoSuchPrefab", null, false)); + } + + [Test] + public void AuditPrefabs_FindsSetupProblems() + { + CreatePrefab("Orphan", typeof(Components.NetworkTransform)); + var nested = new GameObject("Nested", typeof(NetworkObject)); + new GameObject("Child", typeof(NetworkObject)).transform.SetParent(nested.transform); + PrefabUtility.SaveAsPrefabAsset(nested, $"{k_Folder}/Nested.prefab"); + UnityEngine.Object.DestroyImmediate(nested); + CreatePrefab("Unregistered", typeof(NetworkObject)); + CreatePrefab("NotNetworked"); + + var result = NgoCliQueries.AuditPrefabs(k_Folder, 50); + var codes = result.Issues.Select(i => (i.Code, i.Asset)).ToList(); + + var found = string.Join(", ", codes); + CollectionAssert.Contains(codes, ("BEHAVIOUR_WITHOUT_NETWORKOBJECT", $"{k_Folder}/Orphan.prefab"), found); + CollectionAssert.Contains(codes, ("NESTED_NETWORKOBJECT", $"{k_Folder}/Nested.prefab"), found); + CollectionAssert.Contains(codes, ("NOT_REGISTERED", $"{k_Folder}/Unregistered.prefab"), found); + Assert.AreEqual(result.Issues.Count(i => i.Severity == "error"), result.Errors); + Assert.AreEqual(3, result.PrefabsScanned, $"The prefab without netcode scripts isn't loaded. Found: {found}"); + + var truncated = NgoCliQueries.AuditPrefabs(k_Folder, 1); + Assert.AreEqual(1, truncated.Issues.Count); + Assert.IsTrue(truncated.Truncated); + Assert.Throws(() => NgoCliQueries.AuditPrefabs("Assets/NoSuchFolder", 10)); + + // Without a NetworkManager open, the project audit still checks the prefabs and says so. + var all = NgoCliQueries.Audit("all", k_Folder, 50, null); + Assert.AreEqual(3, all.PrefabsScanned); + Assert.AreEqual(0, all.ManagersChecked); + Assert.AreEqual("NO_NETWORKMANAGER", all.Issues[0].Code); + Assert.IsTrue(all.Issues.Skip(1).All(i => i.Area == "prefabs")); + Assert.AreEqual(result.Warnings + 1, all.Warnings); + Assert.IsNull(NgoCliQueries.Audit("prefabs", k_Folder, 50, null).ManagersChecked); + Assert.Throws(() => NgoCliQueries.Audit("config", null, 50, null), "Asking for the NetworkManager checks needs one."); + } + + // --- Config --- + + [Test] + public void GetConfig_ReportsSnakeCaseFields() + { + CreateManager(port: 7802); + + var config = NgoCliQueries.GetConfig(null); + + Assert.AreEqual(30u, config.NetworkConfig["tick_rate"]); + Assert.AreEqual("ClientServer", config.NetworkConfig["network_topology"]); + Assert.IsTrue(config.NetworkConfig.ContainsKey("use_cmb_service")); + CollectionAssert.Contains(config.SettableFields, "port"); + CollectionAssert.Contains(config.SettableFields, "log_level"); + Assert.AreEqual(7802, config.Transport.Port); + } + + [Test] + public void SetConfig_ValidatesAndApplies() + { + var manager = CreateManager(); + + var dryRun = NgoCliQueries.SetConfig("tick_rate", "60", null, true); + Assert.AreEqual(30u, dryRun.Previous); + Assert.AreEqual(30u, manager.NetworkConfig.TickRate, "dry_run changes nothing."); + + NgoCliQueries.SetConfig("tick_rate", "60", null, false); + Assert.AreEqual(60u, manager.NetworkConfig.TickRate); + NgoCliQueries.SetConfig("connection_approval", "true", null, false); + Assert.IsTrue(manager.NetworkConfig.ConnectionApproval); + NgoCliQueries.SetConfig("network_topology", "distributedauthority", null, false); + Assert.AreEqual(NetworkTopologyTypes.DistributedAuthority, manager.NetworkConfig.NetworkTopology); + var transport = (UnityTransport)manager.NetworkConfig.NetworkTransport; + Assert.AreEqual(string.Empty, transport.ConnectionData.ServerListenAddress, "The listen address isn't set to the connect address."); + transport.ConnectionData.WebSocketPath = "/game"; + NgoCliQueries.SetConfig("port", "9100", null, false); + Assert.AreEqual(9100, transport.ConnectionData.Port); + Assert.AreEqual("/game", transport.ConnectionData.WebSocketPath, "Other connection settings are kept."); + + Assert.Throws(() => NgoCliQueries.SetConfig("load_scene_time_out", "0", null, false)); + Assert.Throws(() => NgoCliQueries.SetConfig("tick_rate", "0", null, false)); + Assert.Throws(() => NgoCliQueries.SetConfig("tick_rate", "fast", null, false)); + var unknown = Assert.Throws(() => NgoCliQueries.SetConfig("no_such_field", "1", null, false)); + StringAssert.Contains("tick_rate", unknown.Message); + + Undo.PerformUndo(); + Assert.AreEqual(7777, ((UnityTransport)manager.NetworkConfig.NetworkTransport).ConnectionData.Port, "Each change is its own undo step."); + Assert.AreEqual(60u, manager.NetworkConfig.TickRate); + + var serialized = new SerializedObject(transport); + serialized.FindProperty("m_ProtocolType").enumValueIndex = (int)UnityTransport.ProtocolType.RelayUnityTransport; + serialized.ApplyModifiedPropertiesWithoutUndo(); + var relay = Assert.Throws(() => NgoCliQueries.SetConfig("port", "9200", null, false)); + StringAssert.Contains("Relay", relay.Message); + } + + [Test] + public void SetConfig_RejectsOutOfRangeAndNonFiniteFloats() + { + var manager = CreateManager(); + var spawnTimeout = manager.NetworkConfig.SpawnTimeout; + + StringAssert.Contains("between", Assert.Throws(() => NgoCliQueries.SetConfig("spawn_timeout", "0", null, false)).Message); + Assert.Throws(() => NgoCliQueries.SetConfig("spawn_timeout", "4000", null, false)); + Assert.Throws(() => NgoCliQueries.SetConfig("spawn_timeout", "NaN", null, false)); + Assert.Throws(() => NgoCliQueries.SetConfig("network_id_recycle_delay", "Infinity", null, false)); + Assert.AreEqual(spawnTimeout, manager.NetworkConfig.SpawnTimeout, "A rejected value changes nothing."); + + NgoCliQueries.SetConfig("spawn_timeout", "30", null, false); + Assert.AreEqual(30f, manager.NetworkConfig.SpawnTimeout); + } + + [Test] + public void SetConfig_LogLevel_ChangesTheManager() + { + var manager = CreateManager(); + + var result = NgoCliQueries.SetConfig("log_level", "developer", null, false); + + Assert.AreEqual("Normal", result.Previous); + Assert.AreEqual(LogLevel.Developer, manager.LogLevel); + Assert.Throws(() => NgoCliQueries.SetConfig("log_level", "Verbose", null, false)); + } + + [Test] + public void ValidateConfig_ReportsMissingTransportAndDistributedAuthorityService() + { + var manager = CreateManager(NetworkTopologyTypes.DistributedAuthority); + manager.NetworkConfig.NetworkTransport = null; + + var audit = NgoCliQueries.Audit("config", null, 50, null); + var codes = audit.Issues.Select(i => i.Code).ToList(); + + Assert.AreEqual(1, audit.ManagersChecked); + Assert.IsTrue(audit.Issues.All(i => i.Area == "config")); + CollectionAssert.Contains(codes, "NO_TRANSPORT"); + CollectionAssert.Contains(codes, "DA_WITHOUT_SERVICE"); + CollectionAssert.Contains(codes, "NO_NETWORK_PREFABS"); + CollectionAssert.Contains(codes, "SCENE_NOT_SAVED", "Scene management is on in an unsaved scene, which opens a dialog in play mode."); + } + + [Test] + public void ToSnakeCase_KeepsAcronymsTogether() + { + Assert.AreEqual("tick_rate", NgoCliQueries.ToSnakeCase("TickRate")); + Assert.AreEqual("use_cmb_service", NgoCliQueries.ToSnakeCase("UseCMBService")); + Assert.AreEqual("rpc_hash_size", NgoCliQueries.ToSnakeCase("RpcHashSize")); + } + + // --- Simulator --- + + [Test] + public void SetSimulator_StoresValuesForTheNextSession() + { + var result = NgoCliQueries.SetSimulator(true, null, 120, 15, 3); + + Assert.IsFalse(result.Previous.Enabled); + Assert.IsTrue(result.Current.Enabled); + Assert.AreEqual(NgoCliSimulator.CustomPreset, result.Current.Preset); + Assert.AreEqual(120, result.Current.PacketDelayMs); + Assert.AreEqual(15, result.Current.PacketJitterMs); + Assert.AreEqual(3, result.Current.PacketDropPercent); + CollectionAssert.IsEmpty(result.AppliedLiveTo); + + var partial = NgoCliQueries.SetSimulator(true, null, -1, -1, 10); + Assert.AreEqual(120, partial.Current.PacketDelayMs, "-1 leaves a value unchanged."); + Assert.AreEqual(10, partial.Current.PacketDropPercent); + + Assert.Throws(() => NgoCliQueries.SetSimulator(true, null, 5000, -1, -1)); + Assert.Throws(() => NgoCliQueries.SetSimulator(true, "NoSuchPreset", -1, -1, -1)); + } + + [Test] + public void SimulatorPresets_ComeFromMultiplayerTools() + { + var presets = NgoCliQueries.ListSimulatorPresets(); + if (presets.Source == null) + { + CollectionAssert.IsEmpty(presets.Presets); + StringAssert.Contains("com.unity.multiplayer.tools", presets.Note); + return; + } + + Assert.IsNotEmpty(presets.Presets); + var preset = presets.Presets.First(p => p.PacketDelayMs > 0); + var result = NgoCliQueries.SetSimulator(true, preset.Name.ToUpperInvariant(), -1, -1, -1); + Assert.AreEqual(preset.Name, result.Current.Preset); + Assert.AreEqual(preset.PacketDelayMs, result.Current.PacketDelayMs); + + // Preset names carry bracketed details; the name before them is enough. + var detailed = presets.Presets.FirstOrDefault(p => p.Name.Contains(" [")); + if (detailed != null) + { + var shortName = detailed.Name.Substring(0, detailed.Name.IndexOf(" [", StringComparison.Ordinal)); + Assert.AreEqual(detailed.Name, NgoCliQueries.SetSimulator(true, shortName, -1, -1, -1).Current.Preset); + } + } + + // --- A running host --- + + /// + /// Starts a host in play mode and drives the runtime queries against it. Play mode is entered without a + /// domain or scene reload so the scene and prefab set up here survive. + /// + [UnityTest] + public IEnumerator RunningHost_SupportsTheRuntimeQueries() + { + var enterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + var enterPlayModeOptions = EditorSettings.enterPlayModeOptions; + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload | EnterPlayModeOptions.DisableSceneReload; + try + { + var prefab = CreatePrefab("CliSpawnable", typeof(NetworkObject)); + var list = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(list, k_ListPath); + NgoCliQueries.RegisterPrefab("CliSpawnable", k_ListPath, false); + var manager = CreateManager(port: (ushort)UnityEngine.Random.Range(20000, 40000)); + manager.NetworkConfig.Prefabs.NetworkPrefabsLists.Clear(); + manager.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(list); + // The unsaved test scene isn't in the build, which with scene management on opens a dialog in play mode. + manager.NetworkConfig.EnableSceneManagement = false; + + yield return new EnterPlayMode(); + + var started = NgoCliQueries.Start(NgoCliQueries.SessionMode.Host, null, null, -1); + Assert.AreEqual("host", started.Status.Role); + Assert.Throws(() => NgoCliQueries.Start(NgoCliQueries.SessionMode.Host, null, null, -1)); + + var ready = NgoCliQueries.WaitReadyAsync(null, 0, 10); + yield return WaitFor(ready); + Assert.IsTrue(ready.Result.Ready, ready.Result.Reason); + + var client = NgoCliQueries.ListClients(null).Clients.Single(); + Assert.AreEqual(0, client.ClientId); + Assert.IsTrue(client.IsLocal); + + var spawned = NgoCliQueries.Spawn("CliSpawnable", new[] { 1f, 2f, 3f }, -1, false, null); + var id = spawned.Object.NetworkObjectId; + CollectionAssert.AreEqual(new[] { 1f, 2f, 3f }, spawned.Object.Position); + Assert.AreEqual(1, NgoCliQueries.ListObjects(null, "CliSpawnable", -1, 10).Total); + var detail = NgoCliQueries.GetObject(id, null); + Assert.AreEqual(0, detail.OwnerClientId); + Assert.AreEqual("The server or host.", detail.Ownership.WhoCanChange); + Assert.Throws(() => NgoCliQueries.GetObject(id + 1000, null)); + + var unchanged = NgoCliQueries.ChangeOwner(id, 0, null); + Assert.AreEqual(0, unchanged.Owner); + Assert.Throws(() => NgoCliQueries.ChangeOwner(id, 42, null)); + Assert.Throws(() => NgoCliQueries.Kick(0, null, null), "The host can't kick its own client."); + + var driver = ((UnityTransport)NgoCliQueries.ResolveManager(null).NetworkConfig.NetworkTransport).GetNetworkDriver(); + driver.CurrentSettings.TryGet(out var original); + var simulator = NgoCliQueries.SetSimulator(true, null, 50, 0, 0); + CollectionAssert.IsNotEmpty(simulator.AppliedLiveTo, "The running transport gets the settings now."); + driver.CurrentSettings.TryGet(out var applied); + Assert.AreEqual(50, applied.PacketDelayMs); + + var timeout = NgoCliQueries.SetTimeout(true); + Assert.IsTrue(timeout.Current.TimeoutActive); + var spike = NgoCliQueries.StartLagSpike(10); + Assert.IsTrue(spike.Current.TimeoutActive, "A lag spike leaves the timeout on."); + yield return WaitForCondition(() => !NgoCliSimulator.LagSpikeActive); + Assert.IsFalse(NgoCliSimulator.LagSpikeActive); + Assert.IsTrue(NgoCliQueries.GetSimulator().Settings.TimeoutActive, "The timeout outlasts the lag spike."); + NgoCliQueries.SetTimeout(false); + + NgoCliQueries.SetSimulator(false, null, -1, -1, -1); + driver.CurrentSettings.TryGet(out var restored); + Assert.AreEqual(original.PacketDelayMs, restored.PacketDelayMs, "Turning the simulator off restores the transport's own settings."); + + var hooks = (IList)typeof(NetworkMessageManager).GetField("m_Hooks", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(NgoCliQueries.ResolveManager(null).MessageManager); + var hookCount = hooks.Count; + var stats = NgoCliQueries.SampleStatsAsync(null, 3, 10); + Assert.AreEqual(hookCount + 1, hooks.Count, "Sampling hooks the message manager."); + yield return WaitFor(stats); + Assert.AreEqual("host", stats.Result.Role); + Assert.GreaterOrEqual(stats.Result.Frames, 1); + Assert.AreEqual(stats.Result.Messages.Sum(m => m.Sent), stats.Result.MessagesSent); + Assert.AreEqual(hookCount, hooks.Count, "Sampling unhooks the message manager when done."); + + var despawned = NgoCliQueries.Despawn(id, true, null); + Assert.IsTrue(despawned.Despawned); + Assert.AreEqual(0, NgoCliQueries.ListObjects(null, "CliSpawnable", -1, 10).Total); + + NgoCliQueries.Stop(null, false); + yield return null; + yield return null; + + yield return new ExitPlayMode(); + } + finally + { + EditorSettings.enterPlayModeOptionsEnabled = enterPlayModeOptionsEnabled; + EditorSettings.enterPlayModeOptions = enterPlayModeOptions; + } + } + + /// + /// Runs a host and a second NetworkManager as its client in the same play session, and drives the commands + /// that act on a remote client: start with a transport override, ownership, kick, reconnect, and a stats + /// sample that the host's shutdown cuts short. + /// + [UnityTest] + public IEnumerator HostWithARemoteClient_SupportsTheClientQueries() + { + var enterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + var enterPlayModeOptions = EditorSettings.enterPlayModeOptions; + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload | EnterPlayModeOptions.DisableSceneReload; + NetworkManager client = null; + try + { + CreatePrefab("CliSpawnable", typeof(NetworkObject)); + var list = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(list, k_ListPath); + NgoCliQueries.RegisterPrefab("CliSpawnable", k_ListPath, false); + var port = (ushort)UnityEngine.Random.Range(20000, 40000); + var host = CreateManager(port: port); + host.NetworkConfig.Prefabs.NetworkPrefabsLists.Clear(); + host.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(list); + // The unsaved test scene isn't in the build, which with scene management on opens a dialog in play mode. + host.NetworkConfig.EnableSceneManagement = false; + // A copy of the host's setup, pointed at the wrong port so start's override has something to fix. + client = UnityEngine.Object.Instantiate(host.gameObject).GetComponent(); + client.name = "ClientManager"; + var clientTransport = (UnityTransport)client.NetworkConfig.NetworkTransport; + Assert.AreSame(client.gameObject, clientTransport.gameObject, "The copy uses its own transport."); + clientTransport.SetConnectionData("127.0.0.1", (ushort)(port + 1)); + + yield return new EnterPlayMode(); + + NgoCliQueries.Start(NgoCliQueries.SessionMode.Host, "NetworkManager", null, -1); + var clientStart = NgoCliQueries.Start(NgoCliQueries.SessionMode.Client, "ClientManager", null, port); + Assert.AreEqual(port, clientTransport.ConnectionData.Port, "start's port overrides the transport's."); + StringAssert.Contains($":{port}", clientStart.Notes[0]); + + var clientReady = NgoCliQueries.WaitReadyAsync("ClientManager", 0, 10); + yield return WaitFor(clientReady); + Assert.IsTrue(clientReady.Result.Ready, clientReady.Result.Reason); + var hostReady = NgoCliQueries.WaitReadyAsync("NetworkManager", 1, 10); + yield return WaitFor(hostReady); + Assert.IsTrue(hostReady.Result.Ready, hostReady.Result.Reason); + + var remoteId = (long)client.LocalClientId; + var clients = NgoCliQueries.ListClients("NetworkManager").Clients; + Assert.AreEqual(2, clients.Count); + Assert.IsFalse(clients.Single(c => c.ClientId == remoteId).IsLocal); + + var id = NgoCliQueries.Spawn("CliSpawnable", null, -1, false, "NetworkManager").Object.NetworkObjectId; + var changed = NgoCliQueries.ChangeOwner(id, remoteId, "NetworkManager"); + Assert.AreEqual(0, changed.PreviousOwner); + Assert.AreEqual(remoteId, changed.Owner); + yield return WaitForCondition(() => client.SpawnManager.SpawnedObjects.ContainsKey((ulong)id)); + Assert.Throws(() => NgoCliQueries.ChangeOwner(id, 0, "ClientManager"), "A client-server client can't change ownership."); + Assert.Throws(() => NgoCliQueries.Kick(remoteId, null, "ClientManager"), "Only the server or host kicks."); + + NgoCliQueries.Kick(remoteId, "Kicked by the CLI test", "NetworkManager"); + yield return WaitForCondition(() => !client.IsListening && !client.ShutdownInProgress); + Assert.IsFalse(client.IsListening, "The kicked client shuts down."); + StringAssert.Contains("Kicked by the CLI test", client.DisconnectReason); + + var reconnected = NgoCliQueries.ReconnectAsync("ClientManager", 10); + yield return WaitFor(reconnected); + Assert.IsTrue(reconnected.Result.Ready, reconnected.Result.Reason); + Assert.IsTrue(client.IsConnectedClient); + Assert.ThrowsAsync(() => NgoCliQueries.ReconnectAsync("NetworkManager", 5), "reconnect is for clients."); + + var stats = NgoCliQueries.SampleStatsAsync("NetworkManager", 10_000, 60); + NgoCliQueries.Stop("NetworkManager", false); + yield return WaitForCondition(() => stats.IsCompleted); + Assert.IsTrue(stats.IsFaulted, "Stopping the host ends the sample with an error."); + StringAssert.Contains("session ended while sampling", stats.Exception.InnerException.Message); + + NgoCliQueries.Stop("ClientManager", false); + yield return WaitForCondition(() => !client.IsListening && !client.ShutdownInProgress); + + yield return new ExitPlayMode(); + } + finally + { + if (client != null) + { + UnityEngine.Object.DestroyImmediate(client.gameObject); + } + EditorSettings.enterPlayModeOptionsEnabled = enterPlayModeOptionsEnabled; + EditorSettings.enterPlayModeOptions = enterPlayModeOptions; + } + } + + /// + /// A distributed authority client that hasn't connected yet can't spawn: the command refuses before it + /// instantiates anything. + /// + [UnityTest] + public IEnumerator Spawn_DistributedAuthorityClientNotConnected_Refuses() + { + var enterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + var enterPlayModeOptions = EditorSettings.enterPlayModeOptions; + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload | EnterPlayModeOptions.DisableSceneReload; + try + { + CreatePrefab("CliSpawnable", typeof(NetworkObject)); + // Nothing listens on this port, so the client stays connecting. + var manager = CreateManager(NetworkTopologyTypes.DistributedAuthority, UnityEngine.Random.Range(20000, 40000)); + manager.NetworkConfig.EnableSceneManagement = false; + + yield return new EnterPlayMode(); + + NgoCliQueries.Start(NgoCliQueries.SessionMode.Client, null, null, -1); + Assert.IsTrue(manager.IsListening); + Assert.IsFalse(manager.IsConnectedClient); + + var refused = Assert.Throws(() => NgoCliQueries.Spawn("CliSpawnable", null, -1, false, null)); + StringAssert.Contains("wait_ready", refused.Message); + Assert.IsNull(GameObject.Find("CliSpawnable(Clone)"), "Nothing is instantiated."); + + NgoCliQueries.Stop(null, true); + yield return WaitForCondition(() => !manager.IsListening && !manager.ShutdownInProgress); + + yield return new ExitPlayMode(); + } + finally + { + EditorSettings.enterPlayModeOptionsEnabled = enterPlayModeOptionsEnabled; + EditorSettings.enterPlayModeOptions = enterPlayModeOptions; + } + } + + private static IEnumerator WaitForCondition(Func condition) + { + var deadline = EditorApplication.timeSinceStartup + 20; + while (!condition() && EditorApplication.timeSinceStartup < deadline) + { + yield return null; + } + } + + private static IEnumerator WaitFor(Task task) + { + yield return WaitForCondition(() => task.IsCompleted); + Assert.IsTrue(task.IsCompleted, "The query didn't finish."); + if (task.IsFaulted) + { + throw task.Exception.InnerException; + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Cli/NgoCliQueriesTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Editor/Cli/NgoCliQueriesTests.cs.meta new file mode 100644 index 0000000000..5f896a41fc --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/Cli/NgoCliQueriesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72bb0384d702b0846ac73565fb083486 \ No newline at end of file