From 1ddd9d768d8bdbf02f44ebf198acf7a51860f1ca Mon Sep 17 00:00:00 2001 From: ZeroGameStudio <837757433@qq.com> Date: Sun, 19 Jul 2026 01:08:51 +0800 Subject: [PATCH 1/2] feat(multiplayer): add reusable room and session package --- README.md | 1 + .../CHANGELOG.md | 16 + .../CHANGELOG.md.meta | 7 + .../Editor.meta | 8 + .../Editor/LocalMultiplayerLauncher.cs | 175 +++ .../Editor/LocalMultiplayerLauncher.cs.meta | 11 + .../Editor/MultiplayerConfigEditor.cs | 33 + .../Editor/MultiplayerConfigEditor.cs.meta | 11 + .../Editor/MultiplayerSetupValidator.cs | 276 ++++ .../Editor/MultiplayerSetupValidator.cs.meta | 11 + .../ZeroEngine.Multiplayer.Editor.asmdef | 19 + .../ZeroEngine.Multiplayer.Editor.asmdef.meta | 7 + .../README.md | 74 + .../README.md.meta | 7 + .../Runtime.meta | 8 + .../Runtime/Core.meta | 8 + .../Runtime/Core/MultiplayerContracts.cs | 81 + .../Runtime/Core/MultiplayerContracts.cs.meta | 11 + .../Runtime/Core/MultiplayerModels.cs | 568 +++++++ .../Runtime/Core/MultiplayerModels.cs.meta | 11 + .../Runtime/Core/MultiplayerSessionConfig.cs | 171 +++ .../Core/MultiplayerSessionConfig.cs.meta | 11 + .../Core/MultiplayerSessionCoordinator.cs | 1343 +++++++++++++++++ .../MultiplayerSessionCoordinator.cs.meta | 11 + .../Runtime/Core/MultiplayerSessionRules.cs | 501 ++++++ .../Core/MultiplayerSessionRules.cs.meta | 11 + .../Runtime/Core/OperationResult.cs | 91 ++ .../Runtime/Core/OperationResult.cs.meta | 11 + .../Core/ZeroEngine.Multiplayer.Core.asmdef | 14 + .../ZeroEngine.Multiplayer.Core.asmdef.meta | 7 + .../Runtime/FishNet.meta | 8 + .../FishNet/FishNetConnectionDriver.cs | 520 +++++++ .../FishNet/FishNetConnectionDriver.cs.meta | 11 + .../Runtime/FishNet/FishNetIdentityBridge.cs | 86 ++ .../FishNet/FishNetIdentityBridge.cs.meta | 11 + .../Runtime/FishNet/MultiplayerBootstrap.cs | 78 + .../FishNet/MultiplayerBootstrap.cs.meta | 11 + .../ZeroEngine.Multiplayer.FishNet.asmdef | 25 + ...ZeroEngine.Multiplayer.FishNet.asmdef.meta | 7 + .../Runtime/Local.meta | 8 + .../Local/LocalDevelopmentRoomOptions.cs | 94 ++ .../Local/LocalDevelopmentRoomOptions.cs.meta | 11 + .../Local/LocalDevelopmentRoomService.cs | 408 +++++ .../Local/LocalDevelopmentRoomService.cs.meta | 11 + .../Local/LocalMultiplayerLaunchArguments.cs | 245 +++ .../LocalMultiplayerLaunchArguments.cs.meta | 11 + .../Local/ZeroEngine.Multiplayer.Local.asmdef | 16 + .../ZeroEngine.Multiplayer.Local.asmdef.meta | 7 + .../Runtime/Presentation.meta | 8 + .../Presentation/MultiplayerViewState.cs | 136 ++ .../Presentation/MultiplayerViewState.cs.meta | 11 + ...ZeroEngine.Multiplayer.Presentation.asmdef | 16 + ...ngine.Multiplayer.Presentation.asmdef.meta | 7 + .../Runtime/Steam.meta | 8 + .../Runtime/Steam/ISteamRuntime.cs | 10 + .../Runtime/Steam/ISteamRuntime.cs.meta | 11 + .../Runtime/Steam/SteamInviteRouter.cs | 55 + .../Runtime/Steam/SteamInviteRouter.cs.meta | 11 + .../Runtime/Steam/SteamRoomMetadata.cs | 283 ++++ .../Runtime/Steam/SteamRoomMetadata.cs.meta | 11 + .../Runtime/Steam/SteamRoomService.cs | 829 ++++++++++ .../Runtime/Steam/SteamRoomService.cs.meta | 11 + .../Runtime/Steam/SteamRuntimeOwner.cs | 168 +++ .../Runtime/Steam/SteamRuntimeOwner.cs.meta | 11 + .../Steam/ZeroEngine.Multiplayer.Steam.asmdef | 31 + .../ZeroEngine.Multiplayer.Steam.asmdef.meta | 7 + .../Samples~/RoomUI.meta | 8 + .../RoomUI/LocalRoomSampleController.cs | 284 ++++ .../RoomUI/LocalRoomSampleController.cs.meta | 11 + .../Samples~/RoomUI/README.md | 15 + .../Samples~/RoomUI/README.md.meta | 7 + ...roEngine.Multiplayer.Samples.RoomUI.asmdef | 27 + ...ine.Multiplayer.Samples.RoomUI.asmdef.meta | 7 + .../Samples~/RoomUI/package.json | 4 + .../Samples~/RoomUI/package.json.meta | 7 + .../Tests.meta | 8 + .../Tests/Editor.meta | 8 + .../Editor/CompatibilityValidatorTests.cs | 76 + .../CompatibilityValidatorTests.cs.meta | 11 + .../LocalDevelopmentRoomServiceTests.cs | 158 ++ .../LocalDevelopmentRoomServiceTests.cs.meta | 11 + .../LocalMultiplayerLaunchArgumentsTests.cs | 78 + ...calMultiplayerLaunchArgumentsTests.cs.meta | 11 + .../Tests/Editor/MultiplayerFakes.cs | 328 ++++ .../Tests/Editor/MultiplayerFakes.cs.meta | 11 + .../MultiplayerSessionCoordinatorTests.cs | 429 ++++++ ...MultiplayerSessionCoordinatorTests.cs.meta | 11 + .../Editor/MultiplayerSessionRulesTests.cs | 221 +++ .../MultiplayerSessionRulesTests.cs.meta | 11 + .../Editor/MultiplayerSetupValidatorTests.cs | 19 + .../MultiplayerSetupValidatorTests.cs.meta | 11 + .../Tests/Editor/MultiplayerViewStateTests.cs | 113 ++ .../Editor/MultiplayerViewStateTests.cs.meta | 11 + .../Tests/Editor/Steam.meta | 8 + .../Editor/Steam/SteamRoomMetadataTests.cs | 132 ++ .../Steam/SteamRoomMetadataTests.cs.meta | 11 + ...gine.Multiplayer.Tests.Steam.Editor.asmdef | 32 + ...Multiplayer.Tests.Steam.Editor.asmdef.meta | 7 + ...ZeroEngine.Multiplayer.Tests.Editor.asmdef | 27 + ...ngine.Multiplayer.Tests.Editor.asmdef.meta | 7 + .../package.json | 32 + .../package.json.meta | 7 + 102 files changed, 8865 insertions(+) create mode 100644 com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md create mode 100644 com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/README.md create mode 100644 com.zerogamestudio.zeroengine.multiplayer/README.md.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef create mode 100644 com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef.meta create mode 100644 com.zerogamestudio.zeroengine.multiplayer/package.json create mode 100644 com.zerogamestudio.zeroengine.multiplayer/package.json.meta diff --git a/README.md b/README.md index 2f34fe66..496dead9 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ mixing in game-specific content. | `com.zerogamestudio.zeroengine.data` | Data and stat systems for game configuration and runtime values. | | `com.zerogamestudio.zeroengine.data-toolkit` | Editor tooling for browsing, inspecting, and validating project data assets. | | `com.zerogamestudio.zeroengine.gameplay` | Reusable gameplay mechanics and trigger helpers. | +| `com.zerogamestudio.zeroengine.multiplayer` | Platform-neutral room, session, invite, retry, and reconnect orchestration. | | `com.zerogamestudio.zeroengine.narrative` | Quest and narrative runtime services. | | `com.zerogamestudio.zeroengine.pathfinding2d` | 2D platform navigation, graph generation, jump links, route costs, and diagnostics. | | `com.zerogamestudio.zeroengine.persistence` | Save and persistence infrastructure. | diff --git a/com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md b/com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md new file mode 100644 index 00000000..25c12485 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## [0.2.0] - Unreleased + +- Add LocalDirect room descriptors, command-line parsing, and connection-driven member snapshots. +- Add optional FishNet 4.6.15 connection driver, authenticated identity bridge, and fail-closed remote admission gate for Tugboat and FishySteamworks. +- Add optional Steamworks.NET 2024.8.0 runtime ownership, lobby metadata, room lifecycle, invitation, host-loss handling, cancellation-safe native operations, and live Lobby membership authorization. +- Publish host phase, joinability, and monotonic session-generation changes through platform-neutral room-state contracts. +- Add setup validation, configuration inspector, local two-process launcher, and LocalDirect Room UI sample. + +## [0.1.0] + +- Add platform-neutral room, connection, and game-adapter contracts. +- Add configurable session, compatibility, invite, reconnect, and stable-seat rules. +- Add a serialized multiplayer session coordinator and read-only presentation state. +- Add fake-backed EditMode coverage for the M1 core package. diff --git a/com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md.meta b/com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md.meta new file mode 100644 index 00000000..55135913 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 56aaf56907ad6f44a9a42b128e2b160e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor.meta b/com.zerogamestudio.zeroengine.multiplayer/Editor.meta new file mode 100644 index 00000000..149d739b --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1334d29327404740a7c410d7e22088a1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs b/com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs new file mode 100644 index 00000000..4524ff26 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs @@ -0,0 +1,175 @@ +using System; +using System.Diagnostics; +using System.IO; +using UnityEditor; +using UnityEngine; +using ZeroEngine.Multiplayer.Local; +using Debug = UnityEngine.Debug; + +namespace ZeroEngine.Multiplayer.Editor +{ + public sealed class LocalMultiplayerLauncher : EditorWindow + { + private string _playerExecutable = string.Empty; + private string _address = "127.0.0.1"; + private int _port = 7770; + private string _roomId = "local-room"; + private string _sessionId = "local-session"; + private string _productId = "sample"; + private string _protocolVersion = "1"; + private string _gameProtocolVersion = "1"; + private string _contentRevision = "sample"; + private string _buildVersion = "development"; + private string _gameRoomId = "sample-room"; + private bool _headless = true; + private bool _exitOnReady; + + [MenuItem("Window/ZeroEngine/Multiplayer/Local Launcher")] + private static void Open() + { + GetWindow("Local Multiplayer"); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Built Player", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + _playerExecutable = EditorGUILayout.TextField("Executable", _playerExecutable); + if (GUILayout.Button("Browse", GUILayout.Width(70f))) + { + string selected = EditorUtility.OpenFilePanel("Select built player", string.Empty, "exe"); + if (!string.IsNullOrEmpty(selected)) + { + _playerExecutable = selected; + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("LocalDirect", EditorStyles.boldLabel); + _address = EditorGUILayout.TextField("Address", _address); + _port = EditorGUILayout.IntField("Port", _port); + _roomId = EditorGUILayout.TextField("Room ID", _roomId); + _sessionId = EditorGUILayout.TextField("Session ID", _sessionId); + _headless = EditorGUILayout.Toggle("Headless", _headless); + _exitOnReady = EditorGUILayout.Toggle("Exit On Ready", _exitOnReady); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Compatibility", EditorStyles.boldLabel); + _productId = EditorGUILayout.TextField("Product", _productId); + _protocolVersion = EditorGUILayout.TextField("Protocol", _protocolVersion); + _gameProtocolVersion = EditorGUILayout.TextField("Game Protocol", _gameProtocolVersion); + _contentRevision = EditorGUILayout.TextField("Content", _contentRevision); + _buildVersion = EditorGUILayout.TextField("Build", _buildVersion); + _gameRoomId = EditorGUILayout.TextField("Game Room", _gameRoomId); + + EditorGUILayout.Space(); + using (new EditorGUI.DisabledScope(!CanLaunch())) + { + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Launch Host")) + { + Launch(LocalMultiplayerRole.Host); + } + + if (GUILayout.Button("Launch Client")) + { + Launch(LocalMultiplayerRole.Client); + } + + if (GUILayout.Button("Launch Both")) + { + Launch(LocalMultiplayerRole.Host); + Launch(LocalMultiplayerRole.Client); + } + EditorGUILayout.EndHorizontal(); + } + + if (GUILayout.Button("Open Logs Folder")) + { + Directory.CreateDirectory(GetLogsDirectory()); + EditorUtility.RevealInFinder(GetLogsDirectory()); + } + } + + public static string BuildProcessArguments( + LocalDevelopmentRoomOptions options, + string logPath, + bool headless) + { + string arguments = LocalMultiplayerLaunchArguments.Build(options) + + " -logFile " + Quote(logPath); + if (headless) + { + arguments += " -batchmode -nographics"; + } + + return arguments; + } + + private bool CanLaunch() + { + return File.Exists(_playerExecutable) && _port > 0 && _port <= ushort.MaxValue && + !string.IsNullOrWhiteSpace(_roomId) && !string.IsNullOrWhiteSpace(_sessionId); + } + + private void Launch(LocalMultiplayerRole role) + { + CompatibilityDescriptor compatibility = new CompatibilityDescriptor( + _productId, + _gameProtocolVersion, + _contentRevision, + _buildVersion, + _gameRoomId); + PlatformUser host = new PlatformUser(new PlatformUserId("local-host"), "Local Host"); + PlatformUser client = new PlatformUser(new PlatformUserId("local-client"), "Local Client"); + LocalDevelopmentRoomOptions options = new LocalDevelopmentRoomOptions( + role, + _address, + (ushort)_port, + new RoomId(_roomId), + new SessionId(_sessionId), + 1, + role == LocalMultiplayerRole.Host ? host : client, + host, + role == LocalMultiplayerRole.Host ? client : host, + compatibility, + _protocolVersion, + 2, + RoomVisibility.Private, + _exitOnReady); + + OperationResult validation = options.Validate(); + if (!validation.Succeeded) + { + Debug.LogError("[ZeroEngine.Multiplayer] Launcher options invalid: " + validation.MessageKey); + return; + } + + string logs = GetLogsDirectory(); + Directory.CreateDirectory(logs); + string roleName = role == LocalMultiplayerRole.Host ? "host" : "client"; + string logPath = Path.Combine(logs, roleName + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmssfff") + ".log"); + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = _playerExecutable, + Arguments = BuildProcessArguments(options, logPath, _headless), + WorkingDirectory = Path.GetDirectoryName(_playerExecutable) ?? Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = _headless + }; + Process.Start(startInfo); + Debug.Log("[ZeroEngine.Multiplayer] Launched " + roleName + ". Log: " + logPath); + } + + private static string GetLogsDirectory() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Logs", "LocalMultiplayer")); + } + + private static string Quote(string value) + { + return "\"" + (value ?? string.Empty).Replace("\"", "\\\"") + "\""; + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs.meta new file mode 100644 index 00000000..34e28eb3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/LocalMultiplayerLauncher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4da4c7e04f4b493eae128af0eb46828e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs new file mode 100644 index 00000000..e76120ba --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.Editor +{ + [CustomEditor(typeof(MultiplayerSessionConfig))] + public sealed class MultiplayerConfigEditor : UnityEditor.Editor + { + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + serializedObject.ApplyModifiedProperties(); + + MultiplayerSessionConfig config = (MultiplayerSessionConfig)target; + IReadOnlyList issues = MultiplayerSetupValidator.ValidateConfig(config); + if (issues.Count == 0) + { + EditorGUILayout.HelpBox("Configuration values are valid.", MessageType.Info); + return; + } + + for (int i = 0; i < issues.Count; i++) + { + EditorGUILayout.HelpBox( + issues[i].Code + "\n" + issues[i].Message, + issues[i].Severity == MultiplayerSetupIssueSeverity.Error + ? MessageType.Error + : MessageType.Warning); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs.meta new file mode 100644 index 00000000..7c503c5f --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerConfigEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 334283fd48ed4d2495d2d830dca578d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs new file mode 100644 index 00000000..b9b9f756 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.Editor +{ + public enum MultiplayerSetupIssueSeverity + { + Info, + Warning, + Error + } + + public sealed class MultiplayerSetupIssue + { + public MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity severity, + string code, + string message) + { + Severity = severity; + Code = code ?? string.Empty; + Message = message ?? string.Empty; + } + + public MultiplayerSetupIssueSeverity Severity { get; } + public string Code { get; } + public string Message { get; } + } + + public static class MultiplayerSetupValidator + { + private const string SteamOwnerType = "ZeroEngine.Multiplayer.Steam.SteamRuntimeOwner"; + private const string FishNetManagerType = "FishNet.Managing.NetworkManager"; + private const string FishNetDriverType = "ZeroEngine.Multiplayer.FishNet.FishNetConnectionDriver"; + private const string FishySteamworksType = "FishySteamworks.FishySteamworks"; + private const string TugboatType = "FishNet.Transporting.Tugboat.Tugboat"; + + public static IReadOnlyList Validate( + MultiplayerSessionConfig config, + TransportMode transportMode, + bool scanSteamLifecycleSources = true) + { + List issues = new List(); + ValidateConfig(config, issues); + + MonoBehaviour[] behaviours = Resources.FindObjectsOfTypeAll(); + Dictionary typeCounts = CountLoadedSceneTypes(behaviours); + RequireExactlyOne(typeCounts, FishNetManagerType, "FishNet NetworkManager", issues); + RequireExactlyOne(typeCounts, FishNetDriverType, "FishNetConnectionDriver", issues); + + if (transportMode == TransportMode.SteamP2P) + { + RequireExactlyOne(typeCounts, SteamOwnerType, "SteamRuntimeOwner", issues); + RequireAtLeastOne(typeCounts, FishySteamworksType, "FishySteamworks transport", issues); + + int legacySteamManagers = GetCount(typeCounts, "SteamManager"); + if (legacySteamManagers > 0) + { + issues.Add(new MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity.Error, + "multiplayer.setup.legacy_steam_manager_present", + "A legacy SteamManager is loaded beside SteamRuntimeOwner. Remove or explicitly adapt it before enabling Steam multiplayer.")); + } + + if (scanSteamLifecycleSources) + { + ValidateSteamLifecycleSources(issues); + } + } + else + { + RequireAtLeastOne(typeCounts, TugboatType, "Tugboat local transport", issues); + } + + ValidateActiveSceneInBuildSettings(issues); + return issues.AsReadOnly(); + } + + public static IReadOnlyList ValidateConfig( + MultiplayerSessionConfig config) + { + List issues = new List(); + ValidateConfig(config, issues); + return issues.AsReadOnly(); + } + + [MenuItem("Window/ZeroEngine/Multiplayer/Validate Setup")] + private static void ValidateFromMenu() + { + MultiplayerSessionConfig config = FindSingleConfig(); + TransportMode mode = config == null ? TransportMode.LocalDirect : config.DefaultTransport; + IReadOnlyList issues = Validate(config, mode); + if (issues.Count == 0) + { + Debug.Log("[ZeroEngine.Multiplayer] Setup validation passed."); + return; + } + + for (int i = 0; i < issues.Count; i++) + { + MultiplayerSetupIssue issue = issues[i]; + string message = "[ZeroEngine.Multiplayer] " + issue.Code + ": " + issue.Message; + if (issue.Severity == MultiplayerSetupIssueSeverity.Error) + { + Debug.LogError(message); + } + else if (issue.Severity == MultiplayerSetupIssueSeverity.Warning) + { + Debug.LogWarning(message); + } + else + { + Debug.Log(message); + } + } + } + + private static void ValidateConfig( + MultiplayerSessionConfig config, + ICollection issues) + { + if (config == null) + { + issues.Add(new MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity.Error, + "multiplayer.setup.config_missing", + "Assign a MultiplayerSessionConfig.")); + return; + } + + IReadOnlyList errors = config.ValidateConfiguration(); + for (int i = 0; i < errors.Count; i++) + { + issues.Add(new MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity.Error, + errors[i], + "MultiplayerSessionConfig contains an invalid value.")); + } + } + + private static Dictionary CountLoadedSceneTypes(IEnumerable behaviours) + { + Dictionary counts = new Dictionary(StringComparer.Ordinal); + foreach (MonoBehaviour behaviour in behaviours) + { + if (behaviour == null || behaviour.gameObject == null || !behaviour.gameObject.scene.IsValid()) + { + continue; + } + + Type type = behaviour.GetType(); + Increment(counts, type.FullName ?? type.Name); + Increment(counts, type.Name); + } + + return counts; + } + + private static void ValidateSteamLifecycleSources(ICollection issues) + { + string[] scriptGuids = AssetDatabase.FindAssets("t:MonoScript", new[] { "Assets" }); + for (int i = 0; i < scriptGuids.Length; i++) + { + string assetPath = AssetDatabase.GUIDToAssetPath(scriptGuids[i]); + if (string.IsNullOrWhiteSpace(assetPath) || + assetPath.EndsWith("SteamRuntimeOwner.cs", StringComparison.Ordinal) || + !File.Exists(assetPath)) + { + continue; + } + + string source; + try + { + source = File.ReadAllText(assetPath); + } + catch (IOException) + { + continue; + } + + if (source.IndexOf("SteamAPI.Init(", StringComparison.Ordinal) >= 0 || + source.IndexOf("SteamAPI.RunCallbacks(", StringComparison.Ordinal) >= 0 || + source.IndexOf("SteamAPI.Shutdown(", StringComparison.Ordinal) >= 0) + { + issues.Add(new MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity.Error, + "multiplayer.setup.external_steam_lifecycle_call", + "Steam lifecycle call found outside SteamRuntimeOwner: " + assetPath)); + } + } + } + + private static void ValidateActiveSceneInBuildSettings(ICollection issues) + { + string activeScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().path; + if (string.IsNullOrWhiteSpace(activeScene)) + { + return; + } + + EditorBuildSettingsScene[] scenes = EditorBuildSettings.scenes; + for (int i = 0; i < scenes.Length; i++) + { + if (scenes[i].enabled && string.Equals(scenes[i].path, activeScene, StringComparison.Ordinal)) + { + return; + } + } + + issues.Add(new MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity.Warning, + "multiplayer.setup.active_scene_not_in_build", + "The active multiplayer scene is not enabled in Build Settings: " + activeScene)); + } + + private static MultiplayerSessionConfig FindSingleConfig() + { + string[] guids = AssetDatabase.FindAssets("t:MultiplayerSessionConfig"); + if (guids.Length == 0) + { + return null; + } + + return AssetDatabase.LoadAssetAtPath( + AssetDatabase.GUIDToAssetPath(guids[0])); + } + + private static void RequireExactlyOne( + IDictionary counts, + string typeName, + string displayName, + ICollection issues) + { + int count = GetCount(counts, typeName); + if (count != 1) + { + issues.Add(new MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity.Error, + "multiplayer.setup.component_count_invalid", + displayName + " count must be exactly one, found " + count + ".")); + } + } + + private static void RequireAtLeastOne( + IDictionary counts, + string typeName, + string displayName, + ICollection issues) + { + int count = GetCount(counts, typeName); + if (count < 1) + { + issues.Add(new MultiplayerSetupIssue( + MultiplayerSetupIssueSeverity.Error, + "multiplayer.setup.component_missing", + displayName + " is missing from the loaded scene.")); + } + } + + private static int GetCount(IDictionary counts, string key) + { + int count; + return counts.TryGetValue(key, out count) ? count : 0; + } + + private static void Increment(IDictionary counts, string key) + { + int current; + counts[key] = counts.TryGetValue(key, out current) ? current + 1 : 1; + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs.meta new file mode 100644 index 00000000..4b00f4f9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/MultiplayerSetupValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f447c875cacc44b1998d568b1e1c1c9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef new file mode 100644 index 00000000..c1f6a406 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "ZeroEngine.Multiplayer.Editor", + "rootNamespace": "ZeroEngine.Multiplayer.Editor", + "references": [ + "ZeroEngine.Multiplayer.Core", + "ZeroEngine.Multiplayer.Local" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef.meta new file mode 100644 index 00000000..9f2e63c3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Editor/ZeroEngine.Multiplayer.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e61bc4dd1db4933b466171415266cd4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/README.md b/com.zerogamestudio.zeroengine.multiplayer/README.md new file mode 100644 index 00000000..4588bfdd --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/README.md @@ -0,0 +1,74 @@ +# ZeroEngine.Multiplayer + +Configurable room and multiplayer-session orchestration for ZeroEngine projects. + +The `Core` assembly owns public models, configuration, state transitions, compatibility checks, invite routing, reconnect policy, stable-seat validation, and the session coordinator. It does not reference Steamworks, FishNet, or a game project's runtime types. Optional assemblies provide LocalDirect rooms, FishNet transport control, Steam lobbies, editor validation, and a read-only presentation state. + +## Status + +Version `0.2.0` contains the M2 implementation: + +- `LocalDevelopmentRoomService` and a stable command-line descriptor for two-process development rooms. +- `FishNetConnectionDriver` for Tugboat LocalDirect or FishySteamworks SteamP2P, plus an identity bridge based on FishNet's authenticated connection event. +- the single-owner `SteamRuntimeOwner`, Steam lobby metadata/service, invitations, refresh, leave, and host-loss detection. +- host-owned room-state publishing for phase, joinability, and monotonic session generation, plus a fail-closed remote admission gate. +- Setup Validator, config inspector, local player launcher, and the importable **LocalDirect Room UI** sample. + +LocalDirect identities are explicitly untrusted development identities. SteamP2P resolves the remote platform identity from FishySteamworks' authenticated server connection address. + +This package is separate from `com.zerogamestudio.zeroengine.network`; it does not replace or migrate that package's NGO/UGS path. + +## Optional dependency baseline + +The package itself has no hard UPM dependency so `Core`, `Local`, `Presentation`, and editor configuration tooling still compile without a networking SDK. The FishNet and Steam assemblies become active only when their packages are present. + +The tested baseline is fixed to: + +| Dependency | Version | Pinned source commit | +| --- | --- | --- | +| FishNet | 4.6.15 | `caf94d2cf5bd7cd2531452b5bf68d20c9a8eae1a` (`4.6.15.1` tag) | +| FishySteamworks | 4.1.1 | `21e858249249e2c322365fe9fefbe865f290b0d9` | +| Steamworks.NET | 2024.8.0 | `a2fc889ab2672981ec3e6225d551d86ce6923121` | + +Pin normalized package mirrors built from these commits; do not track a moving branch. The upstream FishNet tag declares the non-UPM version `4.6.15.1` and includes duplicate quaternion-compression sources on Windows, while FishySteamworks is a repository-root package whose tagged manifest still declares `4.1.0`. Consequently, the two upstream repositories cannot be installed with the previously suggested direct `?path=` URLs. A tested internal mirror or vendored package must normalize FishNet to `4.6.15`, remove the duplicate compression sources, and normalize FishySteamworks to `4.1.1` without changing runtime code. + +```json +"com.firstgeargames.fishnet": "#", +"com.firstgeargames.fishysteamworks": "#", +"com.rlabrecque.steamworks.net": "https://github.com/rlabrecque/Steamworks.NET.git?path=com.rlabrecque.steamworks.net#a2fc889ab2672981ec3e6225d551d86ce6923121" +``` + +The source commits in the table remain the provenance pins; the mirror commits record only packaging normalization. Do not use LLS's Unity-6-modified FishNet copy as the Unity 2022 source, and do not retain Asset-folder copies with the same assembly names when installing these UPM packages. + +## Core flow + +1. Construct `MultiplayerSessionCoordinator` with one `MultiplayerSessionConfig`, an `IPlatformRoomService`, an `INetworkConnectionDriver`, and an `IMultiplayerGameAdapter`. +2. Initialize, create or join, and consume `MultiplayerSessionSnapshot` or `MultiplayerViewState` from project UI. +3. A joining client remains in `Synchronizing` after local preparation. The authoritative game/network bridge must call `ConfirmLocalSynchronization` with the current session ID and generation before the client becomes `Ready` or returns to `InGame`. +4. Server-side peer synchronization and restoration use `SynchronizePeerAsync` and `RestorePeerAsync`; the package never reads game-specific state. + +Host room services may implement `IRoomStatePublisher`; the coordinator then publishes `InRoom`, `Ready`, `Starting`, rollback, and `InGame` state without exposing platform APIs to UI or game code. A room service used by `MultiplayerBootstrap` as a FishNet host must implement `IRemoteConnectionAuthorizer`. The bootstrap wires this automatically: Steam re-reads current Lobby metadata and membership before admitting an authenticated FishySteamworks identity, while LocalDirect accepts only the explicitly configured development identity. + +`MultiplayerSessionConfig` exposes room sizing, operation timeouts, compatibility metadata, transport defaults, logging, and reconnect limits. Its default reconnect schedule is three attempts with 0.5/1.5/3 second delays, four seconds per attempt, an 18 second client deadline, and a 20 second server seat grace period. + +## Steam ownership + +One `SteamRuntimeOwner` is the only component allowed to call `SteamAPI.Init`, `SteamAPI.RunCallbacks`, and `SteamAPI.Shutdown`. FishySteamworks only initializes relay access and remains subordinate to that runtime. Projects with an existing Steam manager must remove it or provide an explicit replacement runtime before enabling this path. + +Run **Window > ZeroEngine > Multiplayer > Validate Setup** before testing. For a LocalDirect scene it checks the session config, NetworkManager, driver, Tugboat, and Build Settings. For Steam it additionally checks the unique runtime owner, FishySteamworks, legacy owners, and lifecycle call sites. + +## LocalDirect sample and launcher + +Import **LocalDirect Room UI** from Package Manager, follow its README to create a blank FishNet scene, and build a player. Then open **Window > ZeroEngine > Multiplayer > Local Launcher**, select the executable, and launch Host, Client, or both. Each process receives an explicit room/session/compatibility descriptor and a unique log path. A successful blank synchronization emits `ZEROENGINE_M2_READY` in both logs. + +Real Steam remains a manual two-account test: compile/setup validation and LocalDirect dual-process testing do not prove overlay, friend invitation, relay connectivity, or account entitlement. + +## Temporary local development + +During package development only, add this package to a consumer project's `Packages/manifest.json` with a `file:` URL and add `com.zerogamestudio.zeroengine.multiplayer` to `testables` when package tests need to run. + +Shared and production branches must instead use a tested commit: + +```json +"com.zerogamestudio.zeroengine.multiplayer": "https://github.com/liuzqk/zeroengine.git?path=com.zerogamestudio.zeroengine.multiplayer#" +``` diff --git a/com.zerogamestudio.zeroengine.multiplayer/README.md.meta b/com.zerogamestudio.zeroengine.multiplayer/README.md.meta new file mode 100644 index 00000000..05ea37db --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: db386e0de2dfed04ea5165eb174d1de1 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime.meta new file mode 100644 index 00000000..723e5015 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f04f0578beee28047a5cdea6105d204e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core.meta new file mode 100644 index 00000000..cb270e2c --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6c3d808b064a93f408afccff0a1b491e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs new file mode 100644 index 00000000..51099c04 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ZeroEngine.Multiplayer +{ + public interface IPlatformRoomService : IAsyncDisposable + { + bool IsAvailable { get; } + string UnavailableReasonKey { get; } + PlatformUser LocalUser { get; } + RoomSnapshot CurrentRoom { get; } + + event Action RoomEvent; + event Action JoinRequested; + + Task> CreateAsync( + RoomCreateOptions options, + CancellationToken cancellationToken); + + Task> JoinAsync( + RoomId roomId, + CancellationToken cancellationToken); + + Task LeaveAsync(CancellationToken cancellationToken); + OperationResult OpenInviteOverlay(); + Task> RefreshAsync(CancellationToken cancellationToken); + } + + public interface IRoomStatePublisher + { + OperationResult PublishRoomState(RoomSnapshot room); + } + + public interface IRemoteConnectionAuthorizer + { + OperationResult AuthorizeRemoteUser(PlatformUserId userId); + } + + public interface INetworkConnectionDriver + { + ConnectionPhase Phase { get; } + bool IsServer { get; } + bool IsClient { get; } + + event Action ConnectionEvent; + + Task StartHostAsync( + HostConnectionOptions options, + CancellationToken cancellationToken); + + Task StartClientAsync( + ClientConnectionOptions options, + CancellationToken cancellationToken); + + Task StopAsync( + DisconnectIntent intent, + CancellationToken cancellationToken); + } + + public interface IMultiplayerGameAdapter + { + CompatibilityDescriptor GetCompatibility(); + + Task PrepareSessionAsync( + MultiplayerSessionContext context, + CancellationToken cancellationToken); + + Task SynchronizePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken); + + Task RestorePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken); + + void OnPeerDisconnected(MultiplayerPeer peer, TimeSpan gracePeriod); + void OnPeerReconnectExpired(MultiplayerPeer peer); + void OnSessionEnded(SessionEndReason reason); + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs.meta new file mode 100644 index 00000000..3242f37f --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 73811d13ce8bbf74885d46a4c9c4b0f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs new file mode 100644 index 00000000..e7fc4bd9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs @@ -0,0 +1,568 @@ +using System; +using System.Collections.Generic; + +namespace ZeroEngine.Multiplayer +{ + public readonly struct PlatformUserId : IEquatable + { + public PlatformUserId(string value) + { + Value = value ?? string.Empty; + } + + public string Value { get; } + public bool IsEmpty => string.IsNullOrEmpty(Value); + + public bool Equals(PlatformUserId other) + { + return string.Equals(Value, other.Value, StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return obj is PlatformUserId && Equals((PlatformUserId)obj); + } + + public override int GetHashCode() + { + return StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + } + + public override string ToString() + { + return Value ?? string.Empty; + } + + public static bool operator ==(PlatformUserId left, PlatformUserId right) => left.Equals(right); + public static bool operator !=(PlatformUserId left, PlatformUserId right) => !left.Equals(right); + } + + public readonly struct RoomId : IEquatable + { + public RoomId(string value) + { + Value = value ?? string.Empty; + } + + public string Value { get; } + public bool IsEmpty => string.IsNullOrEmpty(Value); + public bool Equals(RoomId other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + public override bool Equals(object obj) => obj is RoomId && Equals((RoomId)obj); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + public override string ToString() => Value ?? string.Empty; + public static bool operator ==(RoomId left, RoomId right) => left.Equals(right); + public static bool operator !=(RoomId left, RoomId right) => !left.Equals(right); + } + + public readonly struct SessionId : IEquatable + { + public SessionId(string value) + { + Value = value ?? string.Empty; + } + + public string Value { get; } + public bool IsEmpty => string.IsNullOrEmpty(Value); + public bool Equals(SessionId other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + public override bool Equals(object obj) => obj is SessionId && Equals((SessionId)obj); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + public override string ToString() => Value ?? string.Empty; + public static bool operator ==(SessionId left, SessionId right) => left.Equals(right); + public static bool operator !=(SessionId left, SessionId right) => !left.Equals(right); + } + + public enum RoomVisibility + { + Private, + FriendsOnly, + Public + } + + public enum SessionPhase + { + Offline, + Initializing, + Idle, + CreatingRoom, + JoiningRoom, + InRoom, + Connecting, + Synchronizing, + Ready, + Starting, + InGame, + Reconnecting, + Recovery, + Leaving, + Failed + } + + public enum MemberConnectionPhase + { + LobbyOnly, + Connecting, + Connected, + Synchronizing, + Ready, + Disconnected + } + + public enum ConnectionPhase + { + Stopped, + StartingHost, + Hosting, + StartingClient, + Connected, + Stopping, + Failed + } + + public enum RoomEventType + { + RoomUpdated, + MemberJoined, + MemberLeft, + HostLeft, + DataChanged, + Closed + } + + public enum ConnectionEventType + { + HostStarted, + ClientConnected, + PeerConnected, + PeerDisconnected, + LocalDisconnected, + Stopped, + Failed + } + + public enum DisconnectIntent + { + IntentionalLeave, + Retry, + Shutdown, + HostLeft, + Failure + } + + public enum SessionEndReason + { + IntentionalLeave, + HostLeft, + ReconnectExpired, + Failure, + Shutdown + } + + public enum TransportMode + { + SteamP2P, + LocalDirect + } + + public enum BuildMatchPolicy + { + Exact, + Ignore + } + + public enum MultiplayerLogLevel + { + None, + Error, + Info, + Verbose + } + + public enum MultiplayerErrorCode + { + None, + InvalidConfiguration, + InvalidState, + InvalidArgument, + PlatformUnavailable, + RoomNotFound, + Busy, + Cancelled, + Timeout, + StaleOperation, + CreateFailed, + JoinFailed, + LeaveFailed, + TransportFailed, + TransportUnavailable, + ConnectionTimeout, + IdentityRejected, + SynchronizationFailed, + RoomFull, + RoomStarted, + InviteExpired, + ProductMismatch, + ProtocolMismatch, + GameProtocolMismatch, + ContentMismatch, + BuildMismatch, + GameRoomMismatch, + UnauthorizedPeer, + SeatClaimed, + SessionMismatch, + HostLeft, + ReconnectExpired, + Unknown + } + + public enum InviteRouteAction + { + Present, + ConfirmLeaveCurrentSession, + QueueUntilStable, + IgnoreDuplicate + } + + public enum ReconnectBlockReason + { + None, + Cancelled, + AttemptsExhausted, + HardDeadlineExceeded, + GraceExpired + } + + public enum RetryOperationKind + { + None, + CreateRoom, + JoinRoom + } + + public readonly struct PlatformUser + { + public PlatformUser(PlatformUserId id, string displayName) + { + Id = id; + DisplayName = displayName ?? string.Empty; + } + + public PlatformUserId Id { get; } + public string DisplayName { get; } + } + + public readonly struct RoomMember + { + public RoomMember(PlatformUser user, MemberConnectionPhase connectionPhase, bool isHost, int seatIndex) + { + User = user; + ConnectionPhase = connectionPhase; + IsHost = isHost; + SeatIndex = seatIndex; + } + + public PlatformUser User { get; } + public MemberConnectionPhase ConnectionPhase { get; } + public bool IsHost { get; } + public int SeatIndex { get; } + + public RoomMember WithConnectionPhase(MemberConnectionPhase phase) + { + return new RoomMember(User, phase, IsHost, SeatIndex); + } + } + + public readonly struct CompatibilityDescriptor + { + public CompatibilityDescriptor( + string productId, + string gameProtocolVersion, + string contentRevision, + string buildVersion, + string gameRoomId) + { + ProductId = productId ?? string.Empty; + GameProtocolVersion = gameProtocolVersion ?? string.Empty; + ContentRevision = contentRevision ?? string.Empty; + BuildVersion = buildVersion ?? string.Empty; + GameRoomId = gameRoomId ?? string.Empty; + } + + public string ProductId { get; } + public string GameProtocolVersion { get; } + public string ContentRevision { get; } + public string BuildVersion { get; } + public string GameRoomId { get; } + } + + public readonly struct RoomCreateOptions + { + public RoomCreateOptions( + RoomVisibility visibility, + int maxMembers, + bool allowJoinInProgress, + string protocolVersion, + CompatibilityDescriptor compatibility) + { + Visibility = visibility; + MaxMembers = maxMembers; + AllowJoinInProgress = allowJoinInProgress; + ProtocolVersion = protocolVersion ?? string.Empty; + Compatibility = compatibility; + } + + public RoomVisibility Visibility { get; } + public int MaxMembers { get; } + public bool AllowJoinInProgress { get; } + public string ProtocolVersion { get; } + public CompatibilityDescriptor Compatibility { get; } + } + + public sealed class RoomSnapshot + { + private readonly IReadOnlyList _members; + + public RoomSnapshot( + RoomId id, + PlatformUserId hostId, + IEnumerable members, + int maxMembers, + RoomVisibility visibility, + bool isJoinable, + string productId, + string protocolVersion, + string gameProtocolVersion, + string contentRevision, + string buildVersion, + string gameRoomId, + SessionId sessionId, + long sessionGeneration, + SessionPhase phase, + bool hasStarted, + string connectionAddress = "") + { + Id = id; + HostId = hostId; + _members = new List(members ?? Array.Empty()).AsReadOnly(); + MaxMembers = maxMembers; + Visibility = visibility; + IsJoinable = isJoinable; + ProductId = productId ?? string.Empty; + ProtocolVersion = protocolVersion ?? string.Empty; + GameProtocolVersion = gameProtocolVersion ?? string.Empty; + ContentRevision = contentRevision ?? string.Empty; + BuildVersion = buildVersion ?? string.Empty; + GameRoomId = gameRoomId ?? string.Empty; + SessionId = sessionId; + SessionGeneration = sessionGeneration; + Phase = phase; + HasStarted = hasStarted; + ConnectionAddress = connectionAddress ?? string.Empty; + } + + public RoomId Id { get; } + public PlatformUserId HostId { get; } + public IReadOnlyList Members => _members; + public int MaxMembers { get; } + public RoomVisibility Visibility { get; } + public bool IsJoinable { get; } + public string ProductId { get; } + public string ProtocolVersion { get; } + public string GameProtocolVersion { get; } + public string ContentRevision { get; } + public string BuildVersion { get; } + public string GameRoomId { get; } + public SessionId SessionId { get; } + public long SessionGeneration { get; } + public SessionPhase Phase { get; } + public bool HasStarted { get; } + public string ConnectionAddress { get; } + + public bool IsHost(PlatformUserId userId) + { + return HostId == userId; + } + + public RoomSnapshot WithSession(long generation, SessionPhase phase, bool hasStarted, bool isJoinable) + { + return new RoomSnapshot( + Id, + HostId, + _members, + MaxMembers, + Visibility, + isJoinable, + ProductId, + ProtocolVersion, + GameProtocolVersion, + ContentRevision, + BuildVersion, + GameRoomId, + SessionId, + generation, + phase, + hasStarted, + ConnectionAddress); + } + + public RoomSnapshot WithMemberPhase(PlatformUserId userId, MemberConnectionPhase phase) + { + List members = new List(_members.Count); + for (int i = 0; i < _members.Count; i++) + { + RoomMember member = _members[i]; + members.Add(member.User.Id == userId ? member.WithConnectionPhase(phase) : member); + } + + return new RoomSnapshot( + Id, + HostId, + members, + MaxMembers, + Visibility, + IsJoinable, + ProductId, + ProtocolVersion, + GameProtocolVersion, + ContentRevision, + BuildVersion, + GameRoomId, + SessionId, + SessionGeneration, + Phase, + HasStarted, + ConnectionAddress); + } + } + + public readonly struct JoinRequest + { + public JoinRequest(RoomId roomId, PlatformUser sender) + { + RoomId = roomId; + Sender = sender; + } + + public RoomId RoomId { get; } + public PlatformUser Sender { get; } + } + + public readonly struct PlatformRoomEvent + { + public PlatformRoomEvent( + RoomEventType type, + RoomId roomId, + long sessionGeneration, + RoomSnapshot snapshot, + PlatformUserId memberId) + { + Type = type; + RoomId = roomId; + SessionGeneration = sessionGeneration; + Snapshot = snapshot; + MemberId = memberId; + } + + public RoomEventType Type { get; } + public RoomId RoomId { get; } + public long SessionGeneration { get; } + public RoomSnapshot Snapshot { get; } + public PlatformUserId MemberId { get; } + } + + public readonly struct HostConnectionOptions + { + public HostConnectionOptions(RoomId roomId, SessionId sessionId, long sessionGeneration) + { + RoomId = roomId; + SessionId = sessionId; + SessionGeneration = sessionGeneration; + } + + public RoomId RoomId { get; } + public SessionId SessionId { get; } + public long SessionGeneration { get; } + } + + public readonly struct ClientConnectionOptions + { + public ClientConnectionOptions( + RoomId roomId, + PlatformUserId hostId, + SessionId sessionId, + long sessionGeneration, + string connectionAddress = "") + { + RoomId = roomId; + HostId = hostId; + SessionId = sessionId; + SessionGeneration = sessionGeneration; + ConnectionAddress = connectionAddress ?? string.Empty; + } + + public RoomId RoomId { get; } + public PlatformUserId HostId { get; } + public SessionId SessionId { get; } + public long SessionGeneration { get; } + public string ConnectionAddress { get; } + } + + public readonly struct ConnectionEvent + { + public ConnectionEvent( + ConnectionEventType type, + PlatformUserId userId, + MultiplayerErrorCode errorCode, + string messageKey) + { + Type = type; + UserId = userId; + ErrorCode = errorCode; + MessageKey = messageKey ?? string.Empty; + } + + public ConnectionEventType Type { get; } + public PlatformUserId UserId { get; } + public MultiplayerErrorCode ErrorCode { get; } + public string MessageKey { get; } + } + + public readonly struct MultiplayerSessionContext + { + public MultiplayerSessionContext(RoomSnapshot room, PlatformUser localUser, bool isHost) + { + Room = room; + LocalUser = localUser; + IsHost = isHost; + } + + public RoomSnapshot Room { get; } + public PlatformUser LocalUser { get; } + public bool IsHost { get; } + } + + public readonly struct MultiplayerPeer + { + public MultiplayerPeer(PlatformUser user, int seatIndex) + { + User = user; + SeatIndex = seatIndex; + } + + public PlatformUser User { get; } + public int SeatIndex { get; } + } + + public readonly struct ReconnectAttempt + { + public ReconnectAttempt(int number, TimeSpan delay, TimeSpan timeout) + { + Number = number; + Delay = delay; + Timeout = timeout; + } + + public int Number { get; } + public TimeSpan Delay { get; } + public TimeSpan Timeout { get; } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs.meta new file mode 100644 index 00000000..b48ce278 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 09a5fb183e4fd624a85dad6c446d9bac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs new file mode 100644 index 00000000..ef5f453b --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace ZeroEngine.Multiplayer +{ + [CreateAssetMenu(fileName = "MultiplayerSessionConfig", menuName = "ZeroEngine/Multiplayer/Session Config")] + public sealed class MultiplayerSessionConfig : ScriptableObject + { + [Header("Room")] + [SerializeField] private RoomVisibility defaultVisibility = RoomVisibility.FriendsOnly; + [Min(1)] [SerializeField] private int maxPlayers = 2; + [Min(1)] [SerializeField] private int minimumPlayersToStart = 2; + [SerializeField] private bool allowJoinInProgress; + + [Header("Timeouts (seconds)")] + [Min(0.1f)] [SerializeField] private float createTimeoutSeconds = 10f; + [Min(0.1f)] [SerializeField] private float joinTimeoutSeconds = 10f; + [Min(0.1f)] [SerializeField] private float connectionTimeoutSeconds = 10f; + [Min(0.1f)] [SerializeField] private float initialSyncTimeoutSeconds = 15f; + [Min(0.1f)] [SerializeField] private float startTimeoutSeconds = 15f; + [Min(0.1f)] [SerializeField] private float leaveTimeoutSeconds = 5f; + + [Header("Reconnect")] + [SerializeField] private bool reconnectEnabled = true; + [Min(0.1f)] [SerializeField] private float reconnectGraceSeconds = 20f; + [Min(1)] [SerializeField] private int reconnectMaxAttempts = 3; + [Min(0.1f)] [SerializeField] private float reconnectAttemptTimeoutSeconds = 4f; + [SerializeField] private float[] reconnectRetryIntervalsSeconds = { 0.5f, 1.5f, 3f }; + [Min(0.1f)] [SerializeField] private float reconnectHardDeadlineSeconds = 18f; + + [Header("Compatibility")] + [SerializeField] private string protocolVersion = "1"; + [SerializeField] private BuildMatchPolicy buildMatchPolicy = BuildMatchPolicy.Exact; + [SerializeField] private string metadataPrefix = "ze_"; + + [Header("Transport")] + [SerializeField] private TransportMode defaultTransport = TransportMode.SteamP2P; + [Range(1, 65535)] [SerializeField] private int localPort = 7770; + [SerializeField] private string localDevelopmentArguments = "--multiplayer-role"; + + [Header("Logging")] + [SerializeField] private MultiplayerLogLevel logLevel = MultiplayerLogLevel.Info; + [SerializeField] private bool logStateTransitions = true; + [SerializeField] private bool logNetworkStatistics; + + public RoomVisibility DefaultVisibility => defaultVisibility; + public int MaxPlayers => maxPlayers; + public int MinimumPlayersToStart => minimumPlayersToStart; + public bool AllowJoinInProgress => allowJoinInProgress; + public TimeSpan CreateTimeout => TimeSpan.FromSeconds(createTimeoutSeconds); + public TimeSpan JoinTimeout => TimeSpan.FromSeconds(joinTimeoutSeconds); + public TimeSpan ConnectionTimeout => TimeSpan.FromSeconds(connectionTimeoutSeconds); + public TimeSpan InitialSyncTimeout => TimeSpan.FromSeconds(initialSyncTimeoutSeconds); + public TimeSpan StartTimeout => TimeSpan.FromSeconds(startTimeoutSeconds); + public TimeSpan LeaveTimeout => TimeSpan.FromSeconds(leaveTimeoutSeconds); + public bool ReconnectEnabled => reconnectEnabled; + public TimeSpan ReconnectGracePeriod => TimeSpan.FromSeconds(reconnectGraceSeconds); + public int ReconnectMaxAttempts => reconnectMaxAttempts; + public TimeSpan ReconnectAttemptTimeout => TimeSpan.FromSeconds(reconnectAttemptTimeoutSeconds); + public IReadOnlyList ReconnectRetryIntervalsSeconds => reconnectRetryIntervalsSeconds; + public TimeSpan ReconnectHardDeadline => TimeSpan.FromSeconds(reconnectHardDeadlineSeconds); + public string ProtocolVersion => protocolVersion ?? string.Empty; + public BuildMatchPolicy BuildMatchPolicy => buildMatchPolicy; + public string MetadataPrefix => metadataPrefix ?? string.Empty; + public TransportMode DefaultTransport => defaultTransport; + public int LocalPort => localPort; + public string LocalDevelopmentArguments => localDevelopmentArguments ?? string.Empty; + public MultiplayerLogLevel LogLevel => logLevel; + public bool LogStateTransitions => logStateTransitions; + public bool LogNetworkStatistics => logNetworkStatistics; + + public IReadOnlyList ValidateConfiguration() + { + List errors = new List(); + + if (maxPlayers < 1) + { + errors.Add("multiplayer.config.max_players_invalid"); + } + + if (minimumPlayersToStart < 1 || minimumPlayersToStart > maxPlayers) + { + errors.Add("multiplayer.config.minimum_players_invalid"); + } + + if (createTimeoutSeconds <= 0f || joinTimeoutSeconds <= 0f || + connectionTimeoutSeconds <= 0f || initialSyncTimeoutSeconds <= 0f || + startTimeoutSeconds <= 0f || leaveTimeoutSeconds <= 0f) + { + errors.Add("multiplayer.config.timeout_invalid"); + } + + if (string.IsNullOrWhiteSpace(protocolVersion)) + { + errors.Add("multiplayer.config.protocol_version_missing"); + } + + if (string.IsNullOrWhiteSpace(metadataPrefix)) + { + errors.Add("multiplayer.config.metadata_prefix_missing"); + } + + if (localPort < 1 || localPort > 65535) + { + errors.Add("multiplayer.config.local_port_invalid"); + } + + if (reconnectEnabled) + { + ValidateReconnect(errors); + } + + return errors.AsReadOnly(); + } + + public ReconnectPolicy CreateReconnectPolicy() + { + TimeSpan[] delays = new TimeSpan[reconnectRetryIntervalsSeconds == null ? 0 : reconnectRetryIntervalsSeconds.Length]; + for (int i = 0; i < delays.Length; i++) + { + delays[i] = TimeSpan.FromSeconds(reconnectRetryIntervalsSeconds[i]); + } + + return new ReconnectPolicy( + reconnectMaxAttempts, + ReconnectAttemptTimeout, + delays, + ReconnectHardDeadline, + ReconnectGracePeriod); + } + + private void ValidateReconnect(List errors) + { + if (reconnectMaxAttempts < 1 || reconnectAttemptTimeoutSeconds <= 0f || + reconnectHardDeadlineSeconds <= 0f || reconnectGraceSeconds <= 0f) + { + errors.Add("multiplayer.config.reconnect_limits_invalid"); + return; + } + + if (reconnectRetryIntervalsSeconds == null || reconnectRetryIntervalsSeconds.Length < reconnectMaxAttempts) + { + errors.Add("multiplayer.config.reconnect_intervals_missing"); + return; + } + + double scheduleSeconds = reconnectAttemptTimeoutSeconds * reconnectMaxAttempts; + for (int i = 0; i < reconnectMaxAttempts; i++) + { + if (reconnectRetryIntervalsSeconds[i] < 0f) + { + errors.Add("multiplayer.config.reconnect_interval_negative"); + return; + } + + scheduleSeconds += reconnectRetryIntervalsSeconds[i]; + } + + if (scheduleSeconds > reconnectHardDeadlineSeconds) + { + errors.Add("multiplayer.config.reconnect_schedule_exceeds_deadline"); + } + + if (reconnectHardDeadlineSeconds > reconnectGraceSeconds) + { + errors.Add("multiplayer.config.reconnect_deadline_exceeds_grace"); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs.meta new file mode 100644 index 00000000..c6100ab5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ecb5c09779532754da24ab7aacd047c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs new file mode 100644 index 00000000..1fd9d309 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs @@ -0,0 +1,1343 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ZeroEngine.Multiplayer +{ + public sealed class MultiplayerSessionCoordinator : IAsyncDisposable + { + private readonly MultiplayerSessionConfig _config; + private readonly IPlatformRoomService _platform; + private readonly INetworkConnectionDriver _driver; + private readonly IMultiplayerGameAdapter _game; + private readonly MultiplayerSessionStateMachine _stateMachine; + private readonly OperationGenerationGate _operationGeneration = new OperationGenerationGate(); + private readonly InviteRouter _inviteRouter = new InviteRouter(); + private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1); + + private RoomSnapshot _room; + private OperationResult _lastResult = OperationResult.Success(); + private RetryOperationKind _retryOperation; + private RoomId _retryRoomId; + private bool _operationInProgress; + private bool _intentionalLeave; + private bool _disposed; + private TimeSpan _reconnectRemaining; + + public MultiplayerSessionCoordinator( + MultiplayerSessionConfig config, + IPlatformRoomService platform, + INetworkConnectionDriver driver, + IMultiplayerGameAdapter game) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _driver = driver ?? throw new ArgumentNullException(nameof(driver)); + _game = game ?? throw new ArgumentNullException(nameof(game)); + _stateMachine = new MultiplayerSessionStateMachine(); + _stateMachine.PhaseChanged += OnPhaseChanged; + _platform.RoomEvent += OnRoomEvent; + _platform.JoinRequested += OnJoinRequested; + _driver.ConnectionEvent += OnConnectionEvent; + } + + public SessionPhase Phase => _stateMachine.Phase; + public RoomSnapshot CurrentRoom => _room; + public int OperationGeneration => _operationGeneration.Current; + public bool HasPendingInvite => _inviteRouter.HasPending; + + public event Action Changed; + public event Action JoinRequestRouted; + + public MultiplayerSessionSnapshot GetSnapshot() + { + return new MultiplayerSessionSnapshot( + Phase, + _room, + _platform.LocalUser, + _driver.IsServer, + _operationInProgress, + _retryOperation, + _lastResult, + _reconnectRemaining); + } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return Task.FromResult(busy); + } + + try + { + return Task.FromResult(InitializeCore(cancellationToken)); + } + finally + { + EndOperation(); + } + } + + public async Task> CreateRoomAsync(CancellationToken cancellationToken) + { + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return OperationResult.FromFailure(busy); + } + + try + { + int generation = _operationGeneration.Begin(); + _intentionalLeave = false; + + OperationResult transition = Transition(SessionPhase.CreatingRoom); + if (!transition.Succeeded) + { + return OperationResult.FromFailure(SetLastResult(transition)); + } + + _room = null; + _retryOperation = RetryOperationKind.CreateRoom; + _retryRoomId = default(RoomId); + CompatibilityDescriptor compatibility; + try + { + compatibility = _game.GetCompatibility(); + } + catch (Exception exception) + { + return FailRoomOperation(OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.error.compatibility_provider_failed", + exception.GetType().Name)); + } + + OperationResult descriptor = CompatibilityValidator.ValidateDescriptor( + compatibility, + _config.ProtocolVersion); + if (!descriptor.Succeeded) + { + return FailRoomOperation(descriptor); + } + + RoomCreateOptions options = new RoomCreateOptions( + _config.DefaultVisibility, + _config.MaxPlayers, + _config.AllowJoinInProgress, + _config.ProtocolVersion, + compatibility); + + OperationResult create = await RunWithTimeout( + token => _platform.CreateAsync(options, token), + _config.CreateTimeout, + cancellationToken, + MultiplayerErrorCode.CreateFailed, + "multiplayer.error.create_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!create.Succeeded) + { + return FailRoomOperation(create.Result); + } + + if (create.Value == null) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(OperationResult.Failure( + MultiplayerErrorCode.CreateFailed, + "multiplayer.error.create_returned_no_room")); + } + + _room = create.Value; + transition = Transition(SessionPhase.Connecting); + if (!transition.Succeeded) + { + return FailRoomOperation(transition); + } + + OperationResult start = await RunWithTimeout( + token => _driver.StartHostAsync( + new HostConnectionOptions(_room.Id, _room.SessionId, _room.SessionGeneration), + token), + _config.ConnectionTimeout, + cancellationToken, + MultiplayerErrorCode.TransportFailed, + "multiplayer.error.host_start_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!start.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(start); + } + + OperationResult prepare = await RunWithTimeout( + token => _game.PrepareSessionAsync( + new MultiplayerSessionContext(_room, _platform.LocalUser, true), + token), + _config.InitialSyncTimeout, + cancellationToken, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.host_prepare_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!prepare.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(prepare); + } + + _room = _room + .WithMemberPhase(_platform.LocalUser.Id, MemberConnectionPhase.Ready) + .WithSession(_room.SessionGeneration, SessionPhase.InRoom, false, true); + OperationResult publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(publish); + } + + transition = Transition(SessionPhase.InRoom); + if (!transition.Succeeded) + { + return FailRoomOperation(transition); + } + + _retryOperation = RetryOperationKind.None; + SetLastResult(OperationResult.Success()); + return OperationResult.Success(_room); + } + finally + { + EndOperation(); + } + } + + public async Task> JoinRoomAsync( + RoomId roomId, + CancellationToken cancellationToken) + { + if (roomId.IsEmpty) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.error.room_id_missing"); + } + + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return OperationResult.FromFailure(busy); + } + + try + { + int generation = _operationGeneration.Begin(); + _intentionalLeave = false; + + OperationResult transition = Transition(SessionPhase.JoiningRoom); + if (!transition.Succeeded) + { + return OperationResult.FromFailure(SetLastResult(transition)); + } + + _room = null; + _retryOperation = RetryOperationKind.JoinRoom; + _retryRoomId = roomId; + + OperationResult join = await RunWithTimeout( + token => _platform.JoinAsync(roomId, token), + _config.JoinTimeout, + cancellationToken, + MultiplayerErrorCode.JoinFailed, + "multiplayer.error.join_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!join.Succeeded) + { + return FailRoomOperation(join.Result); + } + + if (join.Value == null) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(OperationResult.Failure( + MultiplayerErrorCode.JoinFailed, + "multiplayer.error.join_returned_no_room")); + } + + _room = join.Value; + if (!_room.IsJoinable) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(OperationResult.Failure( + _room.HasStarted || _room.Phase == SessionPhase.Starting || + _room.Phase == SessionPhase.InGame + ? MultiplayerErrorCode.RoomStarted + : MultiplayerErrorCode.JoinFailed, + "multiplayer.error.room_not_joinable")); + } + + CompatibilityDescriptor localCompatibility; + try + { + localCompatibility = _game.GetCompatibility(); + } + catch (Exception exception) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.error.compatibility_provider_failed", + exception.GetType().Name)); + } + + OperationResult compatibility = CompatibilityValidator.Validate( + _room, + localCompatibility, + _config.ProtocolVersion, + _config.BuildMatchPolicy); + if (!compatibility.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(compatibility); + } + + transition = Transition(SessionPhase.Connecting); + if (!transition.Succeeded) + { + return FailRoomOperation(transition); + } + + OperationResult start = await RunWithTimeout( + token => _driver.StartClientAsync( + new ClientConnectionOptions( + _room.Id, + _room.HostId, + _room.SessionId, + _room.SessionGeneration, + _room.ConnectionAddress), + token), + _config.ConnectionTimeout, + cancellationToken, + MultiplayerErrorCode.TransportFailed, + "multiplayer.error.client_start_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!start.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(start); + } + + Transition(SessionPhase.Synchronizing); + OperationResult prepare = await RunWithTimeout( + token => _game.PrepareSessionAsync( + new MultiplayerSessionContext(_room, _platform.LocalUser, false), + token), + _config.InitialSyncTimeout, + cancellationToken, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.client_prepare_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!prepare.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(prepare); + } + + _room = _room.WithMemberPhase(_platform.LocalUser.Id, MemberConnectionPhase.Synchronizing); + _retryOperation = RetryOperationKind.None; + SetLastResult(OperationResult.Success()); + return OperationResult.Success(_room); + } + finally + { + EndOperation(); + } + } + + public async Task> AcceptJoinRequestAsync(CancellationToken cancellationToken) + { + if (!_inviteRouter.HasPending) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.no_pending_invite"); + } + + bool mustLeave = Phase == SessionPhase.InRoom || Phase == SessionPhase.Ready || + Phase == SessionPhase.InGame || Phase == SessionPhase.Recovery; + bool canJoin = Phase == SessionPhase.Idle || Phase == SessionPhase.Failed || mustLeave; + if (!canJoin) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.invite_not_ready"); + } + + JoinRequest request; + _inviteRouter.TryTakePending(out request); + NotifyChanged(); + + if (mustLeave) + { + OperationResult leave = await LeaveAsync(cancellationToken); + if (!leave.Succeeded) + { + return OperationResult.FromFailure(leave); + } + } + + return await JoinRoomAsync(request.RoomId, cancellationToken); + } + + public OperationResult Invite() + { + if (_room == null || !_room.IsHost(_platform.LocalUser.Id) || + (Phase != SessionPhase.InRoom && Phase != SessionPhase.Ready)) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.invite_not_available")); + } + + return SetLastResult(_platform.OpenInviteOverlay()); + } + + public async Task SynchronizePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken) + { + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return busy; + } + + try + { + int generation = _operationGeneration.Begin(); + if (!_driver.IsServer) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.server_required")); + } + + OperationResult transition = Transition(SessionPhase.Synchronizing); + if (!transition.Succeeded) + { + return SetLastResult(transition); + } + + OperationResult sync = await RunWithTimeout( + token => _game.SynchronizePeerAsync(peer, token), + _config.InitialSyncTimeout, + cancellationToken, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.peer_sync_failed"); + + if (!IsCurrent(generation)) + { + return SetLastResult(StaleResult()); + } + + if (!sync.Succeeded) + { + if (_room != null) + { + _room = _room.WithSession( + _room.SessionGeneration, + SessionPhase.InRoom, + false, + true); + PublishCurrentRoomState(); + } + + Transition(SessionPhase.InRoom); + return SetLastResult(sync); + } + + if (_room != null) + { + _room = _room.WithMemberPhase(peer.User.Id, MemberConnectionPhase.Ready); + } + + SessionPhase target = MeetsReadyConditions() ? SessionPhase.Ready : SessionPhase.InRoom; + if (_room != null) + { + _room = _room.WithSession( + _room.SessionGeneration, + target, + false, + true); + OperationResult publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + _room = _room.WithSession( + _room.SessionGeneration, + SessionPhase.InRoom, + false, + true); + PublishCurrentRoomState(); + Transition(SessionPhase.InRoom); + return SetLastResult(publish); + } + } + + OperationResult targetTransition = Transition(target); + if (!targetTransition.Succeeded) + { + return SetLastResult(targetTransition); + } + + return SetLastResult(OperationResult.Success()); + } + finally + { + EndOperation(); + } + } + + public OperationResult ConfirmLocalSynchronization(SessionId sessionId, long sessionGeneration) + { + if (_room == null || _room.SessionId != sessionId || + _room.SessionGeneration != sessionGeneration) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.SessionMismatch, + "multiplayer.error.sync_session_mismatch")); + } + + if (Phase != SessionPhase.Synchronizing) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.sync_confirmation_unexpected")); + } + + _room = _room.WithMemberPhase(_platform.LocalUser.Id, MemberConnectionPhase.Ready); + _reconnectRemaining = TimeSpan.Zero; + SessionPhase target = _room.HasStarted || _room.Phase == SessionPhase.InGame + ? SessionPhase.InGame + : SessionPhase.Ready; + _room = _room.WithSession( + _room.SessionGeneration, + target, + target == SessionPhase.InGame, + _room.IsJoinable); + OperationResult transition = Transition(target); + return SetLastResult(transition); + } + + public async Task StartGameAsync(CancellationToken cancellationToken) + { + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return busy; + } + + try + { + int operationGeneration = _operationGeneration.Begin(); + if (!MeetsReadyConditions()) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.start_conditions_not_met")); + } + + OperationResult transition = Transition(SessionPhase.Starting); + if (!transition.Succeeded) + { + return SetLastResult(transition); + } + + long startingGeneration = _room.SessionGeneration + 1; + _room = _room.WithSession(startingGeneration, SessionPhase.Starting, false, false); + NotifyChanged(); + + OperationResult publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + _room = _room.WithSession( + startingGeneration + 1, + SessionPhase.Ready, + false, + true); + OperationResult rollbackPublish = PublishCurrentRoomState(); + if (!rollbackPublish.Succeeded) + { + _room = _room.WithSession( + startingGeneration + 1, + SessionPhase.Failed, + false, + false); + Transition(SessionPhase.Failed); + return SetLastResult(rollbackPublish); + } + + Transition(SessionPhase.Ready); + return SetLastResult(publish); + } + + OperationResult prepare = await RunWithTimeout( + token => _game.PrepareSessionAsync( + new MultiplayerSessionContext(_room, _platform.LocalUser, true), + token), + _config.StartTimeout, + cancellationToken, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.start_failed"); + + if (!IsCurrent(operationGeneration)) + { + return SetLastResult(StaleResult()); + } + + if (!prepare.Succeeded) + { + _room = _room.WithSession( + startingGeneration + 1, + SessionPhase.Ready, + false, + true); + OperationResult rollbackPublish = PublishCurrentRoomState(); + if (!rollbackPublish.Succeeded) + { + _room = _room.WithSession( + startingGeneration + 1, + SessionPhase.Failed, + false, + false); + Transition(SessionPhase.Failed); + return SetLastResult(rollbackPublish); + } + + Transition(SessionPhase.Ready); + return SetLastResult(prepare); + } + + _room = _room.WithSession( + startingGeneration, + SessionPhase.InGame, + true, + _config.AllowJoinInProgress); + publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + _room = _room.WithSession( + startingGeneration, + SessionPhase.Failed, + true, + false); + PublishCurrentRoomState(); + Transition(SessionPhase.Failed); + return SetLastResult(publish); + } + + Transition(SessionPhase.InGame); + return SetLastResult(OperationResult.Success()); + } + finally + { + EndOperation(); + } + } + + public async Task RestorePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken) + { + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return busy; + } + + try + { + int generation = _operationGeneration.Begin(); + if (!_driver.IsServer || Phase != SessionPhase.InGame) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.server_restore_not_available")); + } + + OperationResult restore = await RunWithTimeout( + token => _game.RestorePeerAsync(peer, token), + _config.InitialSyncTimeout, + cancellationToken, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.restore_failed"); + + if (!IsCurrent(generation)) + { + return SetLastResult(StaleResult()); + } + + if (!restore.Succeeded) + { + return SetLastResult(restore); + } + + if (_room != null) + { + _room = _room.WithMemberPhase(peer.User.Id, MemberConnectionPhase.Ready); + } + + return SetLastResult(OperationResult.Success()); + } + finally + { + EndOperation(); + } + } + + public Task> RetryAsync(CancellationToken cancellationToken) + { + switch (_retryOperation) + { + case RetryOperationKind.CreateRoom: + return CreateRoomAsync(cancellationToken); + case RetryOperationKind.JoinRoom: + return JoinRoomAsync(_retryRoomId, cancellationToken); + default: + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.retry_not_available")); + } + } + + public OperationResult CancelReconnect() + { + if (Phase != SessionPhase.Reconnecting) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.reconnect_not_active")); + } + + _operationGeneration.Invalidate(); + _reconnectRemaining = TimeSpan.Zero; + Transition(SessionPhase.Recovery); + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.reconnect_cancelled")); + } + + public OperationResult ExpireReconnect() + { + if (Phase != SessionPhase.Reconnecting) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.reconnect_not_active")); + } + + _operationGeneration.Invalidate(); + _reconnectRemaining = TimeSpan.Zero; + Transition(SessionPhase.Recovery); + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.ReconnectExpired, + "multiplayer.error.reconnect_expired")); + } + + public OperationResult ExpirePeerReconnect(MultiplayerPeer peer) + { + if (!_driver.IsServer || Phase != SessionPhase.InGame) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.server_peer_expiry_not_available")); + } + + _game.OnPeerReconnectExpired(peer); + if (_room != null) + { + _room = _room.WithMemberPhase(peer.User.Id, MemberConnectionPhase.Disconnected); + } + + return SetLastResult(OperationResult.Success()); + } + + public void UpdateReconnectElapsed(TimeSpan elapsed) + { + if (Phase != SessionPhase.Reconnecting) + { + return; + } + + _reconnectRemaining = elapsed >= _config.ReconnectHardDeadline + ? TimeSpan.Zero + : _config.ReconnectHardDeadline - elapsed; + NotifyChanged(); + } + + public async Task LeaveAsync(CancellationToken cancellationToken) + { + if (Phase == SessionPhase.Idle) + { + return OperationResult.Success(); + } + + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return busy; + } + + try + { + _operationGeneration.Invalidate(); + _intentionalLeave = true; + + OperationResult transition = Transition(SessionPhase.Leaving); + if (!transition.Succeeded) + { + return SetLastResult(transition); + } + + OperationResult stop = await RunWithTimeout( + token => _driver.StopAsync(DisconnectIntent.IntentionalLeave, token), + _config.LeaveTimeout, + cancellationToken, + MultiplayerErrorCode.LeaveFailed, + "multiplayer.error.stop_failed"); + + OperationResult leave = await RunWithTimeout( + token => _platform.LeaveAsync(token), + _config.LeaveTimeout, + cancellationToken, + MultiplayerErrorCode.LeaveFailed, + "multiplayer.error.leave_failed"); + + _game.OnSessionEnded(SessionEndReason.IntentionalLeave); + _room = null; + _retryOperation = RetryOperationKind.None; + _retryRoomId = default(RoomId); + _inviteRouter.Clear(); + _reconnectRemaining = TimeSpan.Zero; + Transition(SessionPhase.Idle); + + OperationResult result = !stop.Succeeded ? stop : leave; + return SetLastResult(result); + } + finally + { + EndOperation(); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + _stateMachine.PhaseChanged -= OnPhaseChanged; + _platform.RoomEvent -= OnRoomEvent; + _platform.JoinRequested -= OnJoinRequested; + _driver.ConnectionEvent -= OnConnectionEvent; + await _platform.DisposeAsync(); + _operationLock.Dispose(); + } + + private bool TryBeginOperation(out OperationResult failure) + { + ThrowIfDisposed(); + if (!_operationLock.Wait(0)) + { + failure = OperationResult.Failure( + MultiplayerErrorCode.Busy, + "multiplayer.error.operation_in_progress"); + return false; + } + + _operationInProgress = true; + failure = OperationResult.Success(); + NotifyChanged(); + return true; + } + + private OperationResult InitializeCore(CancellationToken cancellationToken) + { + try + { + _operationGeneration.Begin(); + OperationResult transition = Transition(SessionPhase.Initializing); + if (!transition.Succeeded) + { + return SetLastResult(transition); + } + + IReadOnlyList configErrors = _config.ValidateConfiguration(); + if (configErrors.Count > 0) + { + OperationResult failure = OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + configErrors[0]); + Transition(SessionPhase.Failed); + return SetLastResult(failure); + } + + if (!_platform.IsAvailable) + { + OperationResult failure = OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + string.IsNullOrWhiteSpace(_platform.UnavailableReasonKey) + ? "multiplayer.error.platform_unavailable" + : _platform.UnavailableReasonKey); + Transition(SessionPhase.Failed); + return SetLastResult(failure); + } + + cancellationToken.ThrowIfCancellationRequested(); + Transition(SessionPhase.Idle); + return SetLastResult(OperationResult.Success()); + } + catch (OperationCanceledException) + { + OperationResult failure = OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.cancelled"); + TransitionToFailedIfAllowed(); + return SetLastResult(failure); + } + } + + private void EndOperation() + { + _operationInProgress = false; + _operationLock.Release(); + NotifyChanged(); + } + + private OperationResult Transition(SessionPhase target) + { + return _stateMachine.TryTransition(target); + } + + private OperationResult PublishCurrentRoomState() + { + if (_room == null || !_room.IsHost(_platform.LocalUser.Id)) + { + return OperationResult.Success(); + } + + IRoomStatePublisher publisher = _platform as IRoomStatePublisher; + return publisher == null + ? OperationResult.Success() + : publisher.PublishRoomState(_room); + } + + private void TransitionToFailedIfAllowed() + { + if (MultiplayerSessionStateMachine.CanTransition(Phase, SessionPhase.Failed)) + { + Transition(SessionPhase.Failed); + } + } + + private bool IsCurrent(int generation) + { + return _operationGeneration.IsCurrent(generation); + } + + private OperationResult FailRoomOperation(OperationResult failure) + { + TransitionToFailedIfAllowed(); + _room = null; + SetLastResult(failure); + return OperationResult.FromFailure(failure); + } + + private OperationResult StaleRoomResult() + { + return OperationResult.FromFailure(SetLastResult(StaleResult())); + } + + private static OperationResult StaleResult() + { + return OperationResult.Failure( + MultiplayerErrorCode.StaleOperation, + "multiplayer.error.stale_operation"); + } + + private OperationResult SetLastResult(OperationResult result) + { + _lastResult = result; + NotifyChanged(); + return result; + } + + private bool MeetsReadyConditions() + { + if (_room == null || !_driver.IsServer || !_room.IsHost(_platform.LocalUser.Id) || + _room.Members.Count < _config.MinimumPlayersToStart) + { + return false; + } + + for (int i = 0; i < _room.Members.Count; i++) + { + if (_room.Members[i].ConnectionPhase != MemberConnectionPhase.Ready) + { + return false; + } + } + + return true; + } + + private async Task CleanupFailedJoinAsync() + { + await RunWithTimeout( + token => _driver.StopAsync(DisconnectIntent.Failure, token), + _config.LeaveTimeout, + CancellationToken.None, + MultiplayerErrorCode.LeaveFailed, + "multiplayer.error.cleanup_stop_failed"); + + await RunWithTimeout( + token => _platform.LeaveAsync(token), + _config.LeaveTimeout, + CancellationToken.None, + MultiplayerErrorCode.LeaveFailed, + "multiplayer.error.cleanup_leave_failed"); + } + + private void OnPhaseChanged(SessionPhase previous, SessionPhase current) + { + NotifyChanged(); + } + + private void OnJoinRequested(JoinRequest request) + { + InviteRouteAction action = _inviteRouter.Route(request, Phase, _room); + Action handler = JoinRequestRouted; + if (handler != null) + { + handler(request, action); + } + + NotifyChanged(); + } + + private void OnRoomEvent(PlatformRoomEvent roomEvent) + { + if (_room == null || roomEvent.RoomId != _room.Id || + roomEvent.SessionGeneration != _room.SessionGeneration) + { + return; + } + + if (roomEvent.Snapshot != null) + { + _room = MergeConnectionPhases(_room, roomEvent.Snapshot); + } + + if (roomEvent.Type == RoomEventType.MemberLeft) + { + if (Phase == SessionPhase.Ready || Phase == SessionPhase.Synchronizing) + { + _room = _room.WithSession( + _room.SessionGeneration, + SessionPhase.InRoom, + false, + true); + Transition(SessionPhase.InRoom); + OperationResult publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + SetLastResult(publish); + } + } + } + else if (roomEvent.Type == RoomEventType.HostLeft) + { + EnterHostLeftRecovery(); + } + + NotifyChanged(); + } + + private static RoomSnapshot MergeConnectionPhases( + RoomSnapshot current, + RoomSnapshot incoming) + { + if (current == null || incoming == null) + { + return incoming; + } + + List members = new List(incoming.Members.Count); + for (int i = 0; i < incoming.Members.Count; i++) + { + RoomMember next = incoming.Members[i]; + for (int j = 0; j < current.Members.Count; j++) + { + RoomMember previous = current.Members[j]; + if (previous.User.Id == next.User.Id) + { + next = next.WithConnectionPhase( + SelectConnectionPhase(previous.ConnectionPhase, next.ConnectionPhase)); + break; + } + } + + members.Add(next); + } + + return new RoomSnapshot( + incoming.Id, + incoming.HostId, + members, + incoming.MaxMembers, + incoming.Visibility, + incoming.IsJoinable, + incoming.ProductId, + incoming.ProtocolVersion, + incoming.GameProtocolVersion, + incoming.ContentRevision, + incoming.BuildVersion, + incoming.GameRoomId, + incoming.SessionId, + incoming.SessionGeneration, + incoming.Phase, + incoming.HasStarted, + incoming.ConnectionAddress); + } + + private static MemberConnectionPhase SelectConnectionPhase( + MemberConnectionPhase current, + MemberConnectionPhase incoming) + { + if (incoming == MemberConnectionPhase.Disconnected) + { + return incoming; + } + + if (current == MemberConnectionPhase.Disconnected) + { + return incoming == MemberConnectionPhase.LobbyOnly ? current : incoming; + } + + return ConnectionPhaseRank(current) >= ConnectionPhaseRank(incoming) + ? current + : incoming; + } + + private static int ConnectionPhaseRank(MemberConnectionPhase phase) + { + switch (phase) + { + case MemberConnectionPhase.Connecting: + return 1; + case MemberConnectionPhase.Connected: + return 2; + case MemberConnectionPhase.Synchronizing: + return 3; + case MemberConnectionPhase.Ready: + return 4; + default: + return 0; + } + } + + private void OnConnectionEvent(ConnectionEvent connectionEvent) + { + if (_intentionalLeave) + { + return; + } + + if (connectionEvent.ErrorCode == MultiplayerErrorCode.HostLeft) + { + EnterHostLeftRecovery(); + return; + } + + if (connectionEvent.Type == ConnectionEventType.LocalDisconnected && + Phase == SessionPhase.InGame && _config.ReconnectEnabled) + { + _reconnectRemaining = _config.ReconnectHardDeadline; + Transition(SessionPhase.Reconnecting); + SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.TransportFailed, + string.IsNullOrWhiteSpace(connectionEvent.MessageKey) + ? "multiplayer.error.connection_lost" + : connectionEvent.MessageKey)); + } + else if (connectionEvent.Type == ConnectionEventType.ClientConnected && + Phase == SessionPhase.Reconnecting) + { + Transition(SessionPhase.Synchronizing); + } + else if (connectionEvent.Type == ConnectionEventType.PeerDisconnected && + Phase == SessionPhase.InGame && _driver.IsServer) + { + MultiplayerPeer peer; + if (TryGetPeer(connectionEvent.UserId, out peer)) + { + _room = _room.WithMemberPhase(peer.User.Id, MemberConnectionPhase.Disconnected); + OperationResult publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + SetLastResult(publish); + } + + _game.OnPeerDisconnected(peer, _config.ReconnectGracePeriod); + NotifyChanged(); + } + } + } + + private bool TryGetPeer(PlatformUserId userId, out MultiplayerPeer peer) + { + if (_room != null) + { + for (int i = 0; i < _room.Members.Count; i++) + { + RoomMember member = _room.Members[i]; + if (member.User.Id == userId) + { + peer = new MultiplayerPeer(member.User, member.SeatIndex); + return true; + } + } + } + + peer = default(MultiplayerPeer); + return false; + } + + private void EnterHostLeftRecovery() + { + if (Phase == SessionPhase.InGame) + { + Transition(SessionPhase.Reconnecting); + } + + if (Phase == SessionPhase.Reconnecting) + { + _reconnectRemaining = TimeSpan.Zero; + Transition(SessionPhase.Recovery); + SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.HostLeft, + "multiplayer.error.host_left")); + } + } + + private void NotifyChanged() + { + Action handler = Changed; + if (handler != null) + { + handler(); + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MultiplayerSessionCoordinator)); + } + } + + private static async Task> RunWithTimeout( + Func>> operation, + TimeSpan timeout, + CancellationToken cancellationToken, + MultiplayerErrorCode exceptionCode, + string exceptionMessageKey) + { + using (CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + timeoutSource.CancelAfter(timeout); + try + { + return await operation(timeoutSource.Token); + } + catch (OperationCanceledException) + { + return cancellationToken.IsCancellationRequested + ? OperationResult.Failure(MultiplayerErrorCode.Cancelled, "multiplayer.error.cancelled") + : OperationResult.Failure(MultiplayerErrorCode.Timeout, "multiplayer.error.timeout"); + } + catch (Exception exception) + { + return OperationResult.Failure( + exceptionCode, + exceptionMessageKey, + exception.GetType().Name); + } + } + } + + private static async Task RunWithTimeout( + Func> operation, + TimeSpan timeout, + CancellationToken cancellationToken, + MultiplayerErrorCode exceptionCode, + string exceptionMessageKey) + { + using (CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + timeoutSource.CancelAfter(timeout); + try + { + return await operation(timeoutSource.Token); + } + catch (OperationCanceledException) + { + return cancellationToken.IsCancellationRequested + ? OperationResult.Failure(MultiplayerErrorCode.Cancelled, "multiplayer.error.cancelled") + : OperationResult.Failure(MultiplayerErrorCode.Timeout, "multiplayer.error.timeout"); + } + catch (Exception exception) + { + return OperationResult.Failure( + exceptionCode, + exceptionMessageKey, + exception.GetType().Name); + } + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs.meta new file mode 100644 index 00000000..b88c0ac5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ad1b93e7ce91ae84998436173a43b874 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs new file mode 100644 index 00000000..b658ab0a --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace ZeroEngine.Multiplayer +{ + public sealed class MultiplayerSessionStateMachine + { + public MultiplayerSessionStateMachine(SessionPhase initialPhase = SessionPhase.Offline) + { + Phase = initialPhase; + } + + public SessionPhase Phase { get; private set; } + + public event Action PhaseChanged; + + public OperationResult TryTransition(SessionPhase target) + { + SessionPhase source = Phase; + if (!CanTransition(source, target)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.invalid_transition", + source.ToString(), + target.ToString()); + } + + Phase = target; + Action handler = PhaseChanged; + if (handler != null) + { + handler(source, target); + } + + return OperationResult.Success(); + } + + public static bool CanTransition(SessionPhase source, SessionPhase target) + { + switch (source) + { + case SessionPhase.Offline: + return target == SessionPhase.Initializing; + case SessionPhase.Initializing: + return target == SessionPhase.Idle || target == SessionPhase.Failed; + case SessionPhase.Idle: + return target == SessionPhase.CreatingRoom || target == SessionPhase.JoiningRoom; + case SessionPhase.CreatingRoom: + return target == SessionPhase.Connecting || target == SessionPhase.Failed; + case SessionPhase.JoiningRoom: + return target == SessionPhase.Connecting || target == SessionPhase.Failed; + case SessionPhase.Connecting: + return target == SessionPhase.InRoom || target == SessionPhase.Synchronizing || + target == SessionPhase.Failed; + case SessionPhase.InRoom: + return target == SessionPhase.Synchronizing || target == SessionPhase.Leaving; + case SessionPhase.Synchronizing: + return target == SessionPhase.Ready || target == SessionPhase.InRoom || + target == SessionPhase.InGame || target == SessionPhase.Failed; + case SessionPhase.Ready: + return target == SessionPhase.Starting || target == SessionPhase.InRoom || + target == SessionPhase.Leaving; + case SessionPhase.Starting: + return target == SessionPhase.InGame || target == SessionPhase.Ready || + target == SessionPhase.Failed; + case SessionPhase.InGame: + return target == SessionPhase.Reconnecting || target == SessionPhase.Leaving; + case SessionPhase.Reconnecting: + return target == SessionPhase.Synchronizing || target == SessionPhase.Recovery; + case SessionPhase.Recovery: + return target == SessionPhase.Leaving; + case SessionPhase.Failed: + return target == SessionPhase.CreatingRoom || target == SessionPhase.JoiningRoom || + target == SessionPhase.Idle || target == SessionPhase.Leaving; + case SessionPhase.Leaving: + return target == SessionPhase.Idle; + default: + return false; + } + } + } + + public static class CompatibilityValidator + { + public static OperationResult ValidateDescriptor( + CompatibilityDescriptor descriptor, + string protocolVersion) + { + if (string.IsNullOrWhiteSpace(descriptor.ProductId) || + string.IsNullOrWhiteSpace(descriptor.GameProtocolVersion) || + string.IsNullOrWhiteSpace(descriptor.ContentRevision) || + string.IsNullOrWhiteSpace(descriptor.BuildVersion) || + string.IsNullOrWhiteSpace(descriptor.GameRoomId) || + string.IsNullOrWhiteSpace(protocolVersion)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.error.compatibility_descriptor_incomplete"); + } + + return OperationResult.Success(); + } + + public static OperationResult Validate( + RoomSnapshot room, + CompatibilityDescriptor local, + string protocolVersion, + BuildMatchPolicy buildMatchPolicy) + { + if (room == null) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.error.room_missing"); + } + + OperationResult descriptor = ValidateDescriptor(local, protocolVersion); + if (!descriptor.Succeeded) + { + return descriptor; + } + + OperationResult result = Match( + local.ProductId, + room.ProductId, + MultiplayerErrorCode.ProductMismatch, + "multiplayer.error.product_mismatch"); + if (!result.Succeeded) + { + return result; + } + + result = Match( + protocolVersion, + room.ProtocolVersion, + MultiplayerErrorCode.ProtocolMismatch, + "multiplayer.error.protocol_mismatch"); + if (!result.Succeeded) + { + return result; + } + + result = Match( + local.GameProtocolVersion, + room.GameProtocolVersion, + MultiplayerErrorCode.GameProtocolMismatch, + "multiplayer.error.game_protocol_mismatch"); + if (!result.Succeeded) + { + return result; + } + + result = Match( + local.ContentRevision, + room.ContentRevision, + MultiplayerErrorCode.ContentMismatch, + "multiplayer.error.content_mismatch"); + if (!result.Succeeded) + { + return result; + } + + if (buildMatchPolicy == BuildMatchPolicy.Exact) + { + result = Match( + local.BuildVersion, + room.BuildVersion, + MultiplayerErrorCode.BuildMismatch, + "multiplayer.error.build_mismatch"); + if (!result.Succeeded) + { + return result; + } + } + + return Match( + local.GameRoomId, + room.GameRoomId, + MultiplayerErrorCode.GameRoomMismatch, + "multiplayer.error.game_room_mismatch"); + } + + private static OperationResult Match( + string local, + string remote, + MultiplayerErrorCode errorCode, + string messageKey) + { + return string.Equals(local, remote, StringComparison.Ordinal) + ? OperationResult.Success() + : OperationResult.Failure(errorCode, messageKey, local ?? string.Empty, remote ?? string.Empty); + } + } + + public sealed class OperationGenerationGate + { + private int _generation; + + public int Current => Volatile.Read(ref _generation); + + public int Begin() + { + return Interlocked.Increment(ref _generation); + } + + public int Invalidate() + { + return Interlocked.Increment(ref _generation); + } + + public bool IsCurrent(int generation) + { + return generation == Current; + } + } + + public sealed class InviteRouter + { + private JoinRequest _pending; + + public bool HasPending { get; private set; } + public JoinRequest Pending => _pending; + + public InviteRouteAction Route(JoinRequest request, SessionPhase phase, RoomSnapshot currentRoom) + { + if ((currentRoom != null && currentRoom.Id == request.RoomId) || + (HasPending && _pending.RoomId == request.RoomId)) + { + return InviteRouteAction.IgnoreDuplicate; + } + + _pending = request; + HasPending = true; + + switch (phase) + { + case SessionPhase.Idle: + case SessionPhase.Failed: + case SessionPhase.Recovery: + return InviteRouteAction.Present; + case SessionPhase.InRoom: + case SessionPhase.Ready: + return InviteRouteAction.ConfirmLeaveCurrentSession; + default: + return InviteRouteAction.QueueUntilStable; + } + } + + public bool TryTakePending(out JoinRequest request) + { + request = _pending; + if (!HasPending) + { + return false; + } + + Clear(); + return true; + } + + public void Clear() + { + _pending = default(JoinRequest); + HasPending = false; + } + } + + public sealed class ReconnectPolicy + { + private readonly TimeSpan[] _delays; + + public ReconnectPolicy( + int maxAttempts, + TimeSpan attemptTimeout, + IReadOnlyList delays, + TimeSpan hardDeadline, + TimeSpan gracePeriod) + { + if (maxAttempts < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxAttempts)); + } + + if (attemptTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(attemptTimeout)); + } + + if (delays == null || delays.Count < maxAttempts) + { + throw new ArgumentException("A delay is required for every reconnect attempt.", nameof(delays)); + } + + if (hardDeadline <= TimeSpan.Zero || gracePeriod < hardDeadline) + { + throw new ArgumentOutOfRangeException(nameof(hardDeadline)); + } + + MaxAttempts = maxAttempts; + AttemptTimeout = attemptTimeout; + HardDeadline = hardDeadline; + GracePeriod = gracePeriod; + _delays = new TimeSpan[maxAttempts]; + + TimeSpan worstCase = TimeSpan.Zero; + for (int i = 0; i < maxAttempts; i++) + { + if (delays[i] < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(delays)); + } + + _delays[i] = delays[i]; + worstCase += delays[i] + attemptTimeout; + } + + if (worstCase > hardDeadline) + { + throw new ArgumentException("Reconnect schedule exceeds the hard deadline.", nameof(delays)); + } + } + + public int MaxAttempts { get; } + public TimeSpan AttemptTimeout { get; } + public TimeSpan HardDeadline { get; } + public TimeSpan GracePeriod { get; } + + public ReconnectBlockReason Evaluate( + int zeroBasedAttempt, + TimeSpan elapsed, + bool cancelled, + out ReconnectAttempt attempt) + { + attempt = default(ReconnectAttempt); + + if (cancelled) + { + return ReconnectBlockReason.Cancelled; + } + + if (elapsed >= GracePeriod) + { + return ReconnectBlockReason.GraceExpired; + } + + if (elapsed >= HardDeadline) + { + return ReconnectBlockReason.HardDeadlineExceeded; + } + + if (zeroBasedAttempt < 0 || zeroBasedAttempt >= MaxAttempts) + { + return ReconnectBlockReason.AttemptsExhausted; + } + + TimeSpan delay = _delays[zeroBasedAttempt]; + TimeSpan remaining = HardDeadline - elapsed - delay; + if (remaining <= TimeSpan.Zero) + { + return ReconnectBlockReason.HardDeadlineExceeded; + } + + TimeSpan timeout = remaining < AttemptTimeout ? remaining : AttemptTimeout; + attempt = new ReconnectAttempt(zeroBasedAttempt + 1, delay, timeout); + return ReconnectBlockReason.None; + } + + public TimeSpan GetRemainingGrace(TimeSpan elapsed) + { + return elapsed >= GracePeriod ? TimeSpan.Zero : GracePeriod - elapsed; + } + } + + public sealed class ReconnectSeatRegistry + { + private readonly Dictionary _seats = new Dictionary(); + + public ReconnectSeatRegistry(SessionId sessionId, long sessionGeneration) + { + SessionId = sessionId; + SessionGeneration = sessionGeneration; + } + + public SessionId SessionId { get; } + public long SessionGeneration { get; } + + public OperationResult Register(int seatIndex, PlatformUserId ownerId) + { + if (seatIndex < 0 || ownerId.IsEmpty) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.error.seat_registration_invalid"); + } + + if (_seats.ContainsKey(seatIndex)) + { + return OperationResult.Failure( + MultiplayerErrorCode.SeatClaimed, + "multiplayer.error.seat_already_registered", + seatIndex.ToString()); + } + + _seats.Add(seatIndex, new SeatReservation(ownerId)); + return OperationResult.Success(); + } + + public OperationResult MarkDisconnected(PlatformUserId ownerId) + { + foreach (KeyValuePair pair in _seats) + { + if (pair.Value.OwnerId == ownerId) + { + pair.Value.IsDisconnected = true; + return OperationResult.Success(); + } + } + + return OperationResult.Failure( + MultiplayerErrorCode.UnauthorizedPeer, + "multiplayer.error.seat_owner_unknown"); + } + + public OperationResult TryReclaim( + int seatIndex, + PlatformUserId claimantId, + SessionId sessionId, + long sessionGeneration) + { + if (sessionId != SessionId || sessionGeneration != SessionGeneration) + { + return OperationResult.Failure( + MultiplayerErrorCode.SessionMismatch, + "multiplayer.error.reconnect_session_mismatch"); + } + + SeatReservation seat; + if (!_seats.TryGetValue(seatIndex, out seat) || seat.OwnerId != claimantId) + { + return OperationResult.Failure( + MultiplayerErrorCode.UnauthorizedPeer, + "multiplayer.error.reconnect_identity_mismatch"); + } + + if (!seat.IsDisconnected) + { + return OperationResult.Failure( + MultiplayerErrorCode.SeatClaimed, + "multiplayer.error.seat_still_connected"); + } + + seat.IsDisconnected = false; + return OperationResult.Success(seatIndex); + } + + private sealed class SeatReservation + { + public SeatReservation(PlatformUserId ownerId) + { + OwnerId = ownerId; + } + + public PlatformUserId OwnerId { get; } + public bool IsDisconnected { get; set; } + } + } + + public readonly struct MultiplayerSessionSnapshot + { + public MultiplayerSessionSnapshot( + SessionPhase phase, + RoomSnapshot room, + PlatformUser localUser, + bool isServer, + bool operationInProgress, + RetryOperationKind retryOperation, + OperationResult lastResult, + TimeSpan reconnectRemaining) + { + Phase = phase; + Room = room; + LocalUser = localUser; + IsServer = isServer; + OperationInProgress = operationInProgress; + RetryOperation = retryOperation; + LastResult = lastResult; + ReconnectRemaining = reconnectRemaining; + } + + public SessionPhase Phase { get; } + public RoomSnapshot Room { get; } + public PlatformUser LocalUser { get; } + public bool IsServer { get; } + public bool OperationInProgress { get; } + public RetryOperationKind RetryOperation { get; } + public OperationResult LastResult { get; } + public TimeSpan ReconnectRemaining { get; } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs.meta new file mode 100644 index 00000000..3db2523f --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8ae20a4d78438364aaef9551bbf01c10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs new file mode 100644 index 00000000..4048df3c --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; + +namespace ZeroEngine.Multiplayer +{ + public readonly struct OperationResult + { + private static readonly IReadOnlyList EmptyArguments = Array.Empty(); + private readonly string _messageKey; + private readonly IReadOnlyList _messageArguments; + + private OperationResult( + bool succeeded, + MultiplayerErrorCode errorCode, + string messageKey, + IReadOnlyList messageArguments) + { + Succeeded = succeeded; + ErrorCode = errorCode; + _messageKey = messageKey ?? string.Empty; + _messageArguments = messageArguments ?? EmptyArguments; + } + + public bool Succeeded { get; } + public MultiplayerErrorCode ErrorCode { get; } + public string MessageKey => _messageKey ?? string.Empty; + public IReadOnlyList MessageArguments => _messageArguments ?? EmptyArguments; + + public static OperationResult Success() + { + return new OperationResult(true, MultiplayerErrorCode.None, string.Empty, EmptyArguments); + } + + public static OperationResult Failure( + MultiplayerErrorCode errorCode, + string messageKey, + params string[] messageArguments) + { + if (errorCode == MultiplayerErrorCode.None) + { + throw new ArgumentException("A failed operation must have an error code.", nameof(errorCode)); + } + + string[] arguments = messageArguments == null + ? Array.Empty() + : (string[])messageArguments.Clone(); + return new OperationResult(false, errorCode, messageKey, Array.AsReadOnly(arguments)); + } + } + + public readonly struct OperationResult + { + private OperationResult(OperationResult result, T value) + { + Result = result; + Value = value; + } + + public OperationResult Result { get; } + public T Value { get; } + public bool Succeeded => Result.Succeeded; + public MultiplayerErrorCode ErrorCode => Result.ErrorCode; + public string MessageKey => Result.MessageKey; + public IReadOnlyList MessageArguments => Result.MessageArguments; + + public static OperationResult Success(T value) + { + return new OperationResult(OperationResult.Success(), value); + } + + public static OperationResult Failure( + MultiplayerErrorCode errorCode, + string messageKey, + params string[] messageArguments) + { + return new OperationResult( + OperationResult.Failure(errorCode, messageKey, messageArguments), + default(T)); + } + + public static OperationResult FromFailure(OperationResult result) + { + if (result.Succeeded) + { + throw new ArgumentException("A generic failure cannot be created from a successful result.", nameof(result)); + } + + return new OperationResult(result, default(T)); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs.meta new file mode 100644 index 00000000..6fd902b3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/OperationResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d20d3cbb15a02d6459d579a6dfd72190 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef new file mode 100644 index 00000000..822f53c7 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef @@ -0,0 +1,14 @@ +{ + "name": "ZeroEngine.Multiplayer.Core", + "rootNamespace": "ZeroEngine.Multiplayer", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef.meta new file mode 100644 index 00000000..32227c4e --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/ZeroEngine.Multiplayer.Core.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 969a7d22dc65c1d41b312534151ab213 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet.meta new file mode 100644 index 00000000..c3d9d211 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cf897bc6766945b4ae229300a7de0093 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs new file mode 100644 index 00000000..227353ce --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs @@ -0,0 +1,520 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FishNet.Connection; +using FishNet.Managing; +using FishNet.Transporting; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.FishNet +{ + public sealed class FishNetConnectionDriver : MonoBehaviour, INetworkConnectionDriver + { + [SerializeField] private NetworkManager networkManager; + [SerializeField] private Transport transport; + [SerializeField] private FishNetIdentityBridge identityBridge; + [SerializeField] private TransportMode transportMode = TransportMode.LocalDirect; + [SerializeField] private string localAddress = "127.0.0.1"; + [SerializeField] private ushort port = 7770; + + private readonly Dictionary _remoteUsers = new Dictionary(); + private bool _subscribed; + private bool _intentionalStop; + private bool _hostLocalClientStarting; + private bool _wasClientActive; + private bool _wasServerActive; + private PlatformUserId _activeHostId; + private IRemoteConnectionAuthorizer _remoteAuthorizer; + + public ConnectionPhase Phase { get; private set; } = ConnectionPhase.Stopped; + public bool IsServer => networkManager != null && networkManager.IsServerStarted; + public bool IsClient => networkManager != null && networkManager.IsClientStarted; + public TransportMode TransportMode => transportMode; + public string LocalAddress => localAddress ?? string.Empty; + public ushort Port => port; + public bool HasRemoteAuthorizer => _remoteAuthorizer != null; + + public event Action ConnectionEvent; + + private void OnEnable() + { + ResolveReferences(); + Subscribe(); + } + + private void OnDisable() + { + Unsubscribe(); + } + + public void Configure( + NetworkManager manager, + Transport selectedTransport, + FishNetIdentityBridge selectedIdentityBridge, + TransportMode mode, + string address, + ushort selectedPort) + { + Unsubscribe(); + networkManager = manager; + transport = selectedTransport; + identityBridge = selectedIdentityBridge; + transportMode = mode; + localAddress = string.IsNullOrWhiteSpace(address) ? "127.0.0.1" : address.Trim(); + port = selectedPort; + ResolveReferences(); + Subscribe(); + } + + public void ConfigureAuthorizer(IRemoteConnectionAuthorizer authorizer) + { + _remoteAuthorizer = authorizer; + } + + public OperationResult ValidateSetup() + { + ResolveReferences(); + if (networkManager == null) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.network_manager_missing"); + } + + if (transport == null || networkManager.TransportManager == null || + !ReferenceEquals(networkManager.TransportManager.Transport, transport)) + { + return OperationResult.Failure( + MultiplayerErrorCode.TransportUnavailable, + "multiplayer.fishnet.active_transport_mismatch"); + } + + if (identityBridge == null || port == 0 || string.IsNullOrWhiteSpace(localAddress)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.driver_configuration_invalid"); + } + + if (identityBridge.TransportMode != transportMode) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.identity_mode_mismatch"); + } + + return OperationResult.Success(); + } + + public async Task StartHostAsync( + HostConnectionOptions options, + CancellationToken cancellationToken) + { + OperationResult setup = ValidateSetup(); + if (!setup.Succeeded) + { + return setup; + } + + if (_remoteAuthorizer == null) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.remote_authorizer_missing"); + } + + if (Phase != ConnectionPhase.Stopped && Phase != ConnectionPhase.Failed) + { + return OperationResult.Failure( + MultiplayerErrorCode.Busy, + "multiplayer.fishnet.driver_busy"); + } + + Phase = ConnectionPhase.StartingHost; + _intentionalStop = false; + _remoteUsers.Clear(); + transport.SetPort(port); + transport.SetClientAddress(localAddress); + + try + { + if (!networkManager.IsServerStarted && !transport.StartConnection(true)) + { + return Fail("multiplayer.fishnet.server_start_rejected"); + } + + await WaitUntilAsync(() => networkManager.IsServerStarted, cancellationToken); + _wasServerActive = true; + + _hostLocalClientStarting = true; + if (!networkManager.IsClientStarted && !transport.StartConnection(false)) + { + await StopStartedTransportsAsync(); + return Fail("multiplayer.fishnet.host_client_start_rejected"); + } + + await WaitUntilAsync(() => networkManager.IsClientStarted, cancellationToken); + _hostLocalClientStarting = false; + _wasClientActive = true; + Phase = ConnectionPhase.Hosting; + Raise(ConnectionEventType.HostStarted, default(PlatformUserId)); + return OperationResult.Success(); + } + catch (OperationCanceledException) + { + _hostLocalClientStarting = false; + await StopStartedTransportsAsync(); + Phase = ConnectionPhase.Stopped; + throw; + } + catch (Exception exception) + { + _hostLocalClientStarting = false; + await StopStartedTransportsAsync(); + return Fail("multiplayer.fishnet.host_start_exception", exception.GetType().Name); + } + } + + public async Task StartClientAsync( + ClientConnectionOptions options, + CancellationToken cancellationToken) + { + OperationResult setup = ValidateSetup(); + if (!setup.Succeeded) + { + return setup; + } + + if (Phase != ConnectionPhase.Stopped && Phase != ConnectionPhase.Failed) + { + return OperationResult.Failure( + MultiplayerErrorCode.Busy, + "multiplayer.fishnet.driver_busy"); + } + + string address = string.IsNullOrWhiteSpace(options.ConnectionAddress) + ? options.HostId.Value + : options.ConnectionAddress; + if (string.IsNullOrWhiteSpace(address)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.fishnet.connection_address_missing"); + } + + Phase = ConnectionPhase.StartingClient; + _intentionalStop = false; + _activeHostId = options.HostId; + transport.SetPort(port); + transport.SetClientAddress(address); + + try + { + if (!networkManager.IsClientStarted && !transport.StartConnection(false)) + { + return Fail("multiplayer.fishnet.client_start_rejected"); + } + + await WaitUntilAsync(() => networkManager.IsClientStarted, cancellationToken); + _wasClientActive = true; + Phase = ConnectionPhase.Connected; + Raise(ConnectionEventType.ClientConnected, _activeHostId); + return OperationResult.Success(); + } + catch (OperationCanceledException) + { + await StopStartedTransportsAsync(); + Phase = ConnectionPhase.Stopped; + throw; + } + catch (Exception exception) + { + await StopStartedTransportsAsync(); + return Fail("multiplayer.fishnet.client_start_exception", exception.GetType().Name); + } + } + + public async Task StopAsync( + DisconnectIntent intent, + CancellationToken cancellationToken) + { + ResolveReferences(); + if (networkManager == null || transport == null) + { + Phase = ConnectionPhase.Stopped; + return OperationResult.Success(); + } + + Phase = ConnectionPhase.Stopping; + _intentionalStop = true; + try + { + if (transport.GetConnectionState(false) != LocalConnectionState.Stopped) + { + transport.StopConnection(false); + await WaitUntilAsync( + () => transport.GetConnectionState(false) == LocalConnectionState.Stopped, + cancellationToken); + } + + if (transport.GetConnectionState(true) != LocalConnectionState.Stopped) + { + transport.StopConnection(true); + await WaitUntilAsync( + () => transport.GetConnectionState(true) == LocalConnectionState.Stopped, + cancellationToken); + } + + _remoteUsers.Clear(); + _wasClientActive = false; + _wasServerActive = false; + _activeHostId = default(PlatformUserId); + Phase = ConnectionPhase.Stopped; + Raise(ConnectionEventType.Stopped, default(PlatformUserId)); + return OperationResult.Success(); + } + catch (OperationCanceledException) + { + Phase = ConnectionPhase.Failed; + throw; + } + catch (Exception exception) + { + return Fail("multiplayer.fishnet.stop_exception", exception.GetType().Name); + } + finally + { + _intentionalStop = false; + } + } + + private void ResolveReferences() + { + if (networkManager == null) + { + networkManager = GetComponentInParent(); + } + + if (networkManager != null && transport == null && networkManager.TransportManager != null) + { + transport = networkManager.TransportManager.Transport; + } + + if (identityBridge == null) + { + identityBridge = GetComponentInParent(); + } + } + + private void Subscribe() + { + if (_subscribed || networkManager == null || transport == null) + { + return; + } + + networkManager.ServerManager.OnAuthenticationResult += OnAuthenticationResult; + networkManager.ServerManager.OnRemoteConnectionState += OnRemoteConnectionState; + transport.OnClientConnectionState += OnClientConnectionState; + transport.OnServerConnectionState += OnServerConnectionState; + _subscribed = true; + } + + private void Unsubscribe() + { + if (!_subscribed) + { + return; + } + + if (networkManager != null && networkManager.ServerManager != null) + { + networkManager.ServerManager.OnAuthenticationResult -= OnAuthenticationResult; + networkManager.ServerManager.OnRemoteConnectionState -= OnRemoteConnectionState; + } + + if (transport != null) + { + transport.OnClientConnectionState -= OnClientConnectionState; + transport.OnServerConnectionState -= OnServerConnectionState; + } + + _subscribed = false; + } + + private void OnAuthenticationResult(NetworkConnection connection, bool authenticated) + { + if (!authenticated || connection == null || _hostLocalClientStarting || IsLocalHostConnection(connection)) + { + return; + } + + OperationResult identity = identityBridge.ResolveRemoteUser(connection, transport); + if (!identity.Succeeded) + { + Raise( + ConnectionEventType.Failed, + default(PlatformUserId), + identity.ErrorCode, + identity.MessageKey); + transport.StopConnection(connection.ClientId, true); + return; + } + + OperationResult authorization; + try + { + authorization = _remoteAuthorizer.AuthorizeRemoteUser(identity.Value); + } + catch (Exception exception) + { + authorization = OperationResult.Failure( + MultiplayerErrorCode.UnauthorizedPeer, + "multiplayer.fishnet.authorization_exception", + exception.GetType().Name); + } + + if (!authorization.Succeeded) + { + Raise( + ConnectionEventType.Failed, + identity.Value, + authorization.ErrorCode, + authorization.MessageKey); + transport.StopConnection(connection.ClientId, true); + return; + } + + _remoteUsers[connection.ClientId] = identity.Value; + Raise(ConnectionEventType.PeerConnected, identity.Value); + } + + private void OnRemoteConnectionState( + NetworkConnection connection, + RemoteConnectionStateArgs state) + { + if (state.ConnectionState != RemoteConnectionState.Stopped) + { + return; + } + + PlatformUserId userId; + if (_remoteUsers.TryGetValue(state.ConnectionId, out userId)) + { + _remoteUsers.Remove(state.ConnectionId); + Raise(ConnectionEventType.PeerDisconnected, userId); + } + } + + private void OnClientConnectionState(ClientConnectionStateArgs state) + { + if (state.ConnectionState == LocalConnectionState.Started) + { + _wasClientActive = true; + return; + } + + if (state.ConnectionState == LocalConnectionState.Stopped && + _wasClientActive && !_intentionalStop) + { + _wasClientActive = false; + Phase = ConnectionPhase.Failed; + Raise( + ConnectionEventType.LocalDisconnected, + _activeHostId, + MultiplayerErrorCode.TransportFailed, + "multiplayer.fishnet.client_disconnected"); + } + } + + private void OnServerConnectionState(ServerConnectionStateArgs state) + { + if (state.ConnectionState == LocalConnectionState.Started) + { + _wasServerActive = true; + return; + } + + if (state.ConnectionState == LocalConnectionState.Stopped && + _wasServerActive && !_intentionalStop) + { + _wasServerActive = false; + Phase = ConnectionPhase.Failed; + Raise( + ConnectionEventType.Failed, + default(PlatformUserId), + MultiplayerErrorCode.TransportFailed, + "multiplayer.fishnet.server_stopped"); + } + } + + private bool IsLocalHostConnection(NetworkConnection connection) + { + return networkManager != null && networkManager.ClientManager != null && + networkManager.ClientManager.Connection != null && + networkManager.ClientManager.Connection.ClientId == connection.ClientId; + } + + private async Task StopStartedTransportsAsync() + { + _intentionalStop = true; + try + { + if (transport != null && transport.GetConnectionState(false) != LocalConnectionState.Stopped) + { + transport.StopConnection(false); + } + + if (transport != null && transport.GetConnectionState(true) != LocalConnectionState.Stopped) + { + transport.StopConnection(true); + } + + await Task.Yield(); + _wasClientActive = false; + _wasServerActive = false; + } + finally + { + _intentionalStop = false; + } + } + + private static async Task WaitUntilAsync(Func condition, CancellationToken cancellationToken) + { + while (!condition()) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } + + private OperationResult Fail(string messageKey, params string[] arguments) + { + Phase = ConnectionPhase.Failed; + Raise( + ConnectionEventType.Failed, + default(PlatformUserId), + MultiplayerErrorCode.TransportFailed, + messageKey); + return OperationResult.Failure( + MultiplayerErrorCode.TransportFailed, + messageKey, + arguments); + } + + private void Raise( + ConnectionEventType type, + PlatformUserId userId, + MultiplayerErrorCode errorCode = MultiplayerErrorCode.None, + string messageKey = "") + { + Action handler = ConnectionEvent; + if (handler != null) + { + handler(new ConnectionEvent(type, userId, errorCode, messageKey)); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs.meta new file mode 100644 index 00000000..3bd1724f --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c25f0620ad84f8e9b2f336bf35bc4c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs new file mode 100644 index 00000000..ca4ba6b8 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs @@ -0,0 +1,86 @@ +using System; +using FishNet.Connection; +using FishNet.Transporting; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.FishNet +{ + public sealed class FishNetIdentityBridge : MonoBehaviour + { + [SerializeField] private TransportMode transportMode = TransportMode.LocalDirect; + [SerializeField] private string localDevelopmentRemoteUserId = "local-client"; + [SerializeField] private bool logUntrustedLocalIdentity = true; + + private bool _loggedUntrustedIdentity; + + public TransportMode TransportMode => transportMode; + public bool IsPlatformAuthenticated => transportMode == TransportMode.SteamP2P; + + public void Configure(TransportMode mode, string localRemoteUserId) + { + transportMode = mode; + localDevelopmentRemoteUserId = localRemoteUserId ?? string.Empty; + _loggedUntrustedIdentity = false; + } + + public OperationResult ResolveRemoteUser( + NetworkConnection connection, + Transport transport) + { + if (connection == null || transport == null || connection.ClientId < 0) + { + return OperationResult.Failure( + MultiplayerErrorCode.IdentityRejected, + "multiplayer.fishnet.identity_context_invalid"); + } + + string connectionAddress; + try + { + connectionAddress = transport.GetConnectionAddress(connection.ClientId); + } + catch (Exception exception) + { + return OperationResult.Failure( + MultiplayerErrorCode.IdentityRejected, + "multiplayer.fishnet.identity_address_failed", + exception.GetType().Name); + } + + if (transportMode == TransportMode.SteamP2P) + { + ulong steamId; + if (string.IsNullOrWhiteSpace(connectionAddress) || + !ulong.TryParse(connectionAddress, out steamId) || steamId == 0) + { + return OperationResult.Failure( + MultiplayerErrorCode.IdentityRejected, + "multiplayer.fishnet.steam_identity_invalid"); + } + + return OperationResult.Success( + new PlatformUserId(steamId.ToString())); + } + + string localIdentity = string.IsNullOrWhiteSpace(localDevelopmentRemoteUserId) + ? "local:" + (connectionAddress ?? string.Empty) + : localDevelopmentRemoteUserId.Trim(); + if (string.IsNullOrWhiteSpace(localIdentity) || string.Equals(localIdentity, "local:", StringComparison.Ordinal)) + { + return OperationResult.Failure( + MultiplayerErrorCode.IdentityRejected, + "multiplayer.fishnet.local_identity_missing"); + } + + if (logUntrustedLocalIdentity && !_loggedUntrustedIdentity) + { + _loggedUntrustedIdentity = true; + Debug.LogWarning( + "[ZeroEngine.Multiplayer] LocalDirect identity is an explicit development identity and is not platform authenticated.", + this); + } + + return OperationResult.Success(new PlatformUserId(localIdentity)); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs.meta new file mode 100644 index 00000000..4715f345 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetIdentityBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f83e5634d4be47df9c325a920cae3f17 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs new file mode 100644 index 00000000..77f4abae --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.FishNet +{ + public sealed class MultiplayerBootstrap : MonoBehaviour + { + [SerializeField] private MultiplayerSessionConfig sessionConfig; + [SerializeField] private FishNetConnectionDriver connectionDriver; + + private IPlatformRoomService _roomService; + private IMultiplayerGameAdapter _gameAdapter; + private bool _disposing; + + public MultiplayerSessionCoordinator Coordinator { get; private set; } + public MultiplayerSessionConfig SessionConfig => sessionConfig; + public FishNetConnectionDriver ConnectionDriver => connectionDriver; + + public void Configure( + MultiplayerSessionConfig config, + IPlatformRoomService roomService, + FishNetConnectionDriver driver, + IMultiplayerGameAdapter gameAdapter) + { + if (Coordinator != null) + { + throw new InvalidOperationException("MultiplayerBootstrap is already configured."); + } + + sessionConfig = config ?? throw new ArgumentNullException(nameof(config)); + _roomService = roomService ?? throw new ArgumentNullException(nameof(roomService)); + connectionDriver = driver ?? throw new ArgumentNullException(nameof(driver)); + _gameAdapter = gameAdapter ?? throw new ArgumentNullException(nameof(gameAdapter)); + connectionDriver.ConfigureAuthorizer(_roomService as IRemoteConnectionAuthorizer); + Coordinator = new MultiplayerSessionCoordinator( + sessionConfig, + _roomService, + connectionDriver, + _gameAdapter); + } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + if (Coordinator == null) + { + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.bootstrap.not_configured")); + } + + return Coordinator.InitializeAsync(cancellationToken); + } + + private async void OnDestroy() + { + if (_disposing || Coordinator == null) + { + return; + } + + _disposing = true; + try + { + await Coordinator.DisposeAsync(); + } + catch (Exception exception) + { + Debug.LogException(exception, this); + } + finally + { + Coordinator = null; + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs.meta new file mode 100644 index 00000000..1da9ed41 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/MultiplayerBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 16911ae1894c4859be305dea156db8aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef new file mode 100644 index 00000000..72b722b8 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef @@ -0,0 +1,25 @@ +{ + "name": "ZeroEngine.Multiplayer.FishNet", + "rootNamespace": "ZeroEngine.Multiplayer.FishNet", + "references": [ + "ZeroEngine.Multiplayer.Core", + "FishNet.Runtime" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "ZEROENGINE_MULTIPLAYER_FISHNET" + ], + "versionDefines": [ + { + "name": "com.firstgeargames.fishnet", + "expression": "[4.6.15,5.0.0)", + "define": "ZEROENGINE_MULTIPLAYER_FISHNET" + } + ], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef.meta new file mode 100644 index 00000000..e4eac992 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/ZeroEngine.Multiplayer.FishNet.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3ad0523637fc4263a8cafc88525e6ec8 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local.meta new file mode 100644 index 00000000..06b02084 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fb6eb1446be24a90b6e09c389c4be461 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs new file mode 100644 index 00000000..d630459b --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs @@ -0,0 +1,94 @@ +using System; + +namespace ZeroEngine.Multiplayer.Local +{ + public enum LocalMultiplayerRole + { + None, + Host, + Client + } + + public sealed class LocalDevelopmentRoomOptions + { + public LocalDevelopmentRoomOptions( + LocalMultiplayerRole role, + string address, + ushort port, + RoomId roomId, + SessionId sessionId, + long sessionGeneration, + PlatformUser localUser, + PlatformUser hostUser, + PlatformUser expectedRemoteUser, + CompatibilityDescriptor compatibility, + string protocolVersion, + int maxMembers, + RoomVisibility visibility, + bool exitOnReady = false) + { + Role = role; + Address = string.IsNullOrWhiteSpace(address) ? "127.0.0.1" : address.Trim(); + Port = port; + RoomId = roomId; + SessionId = sessionId; + SessionGeneration = sessionGeneration; + LocalUser = localUser; + HostUser = hostUser; + ExpectedRemoteUser = expectedRemoteUser; + Compatibility = compatibility; + ProtocolVersion = protocolVersion ?? string.Empty; + MaxMembers = maxMembers; + Visibility = visibility; + ExitOnReady = exitOnReady; + } + + public LocalMultiplayerRole Role { get; } + public string Address { get; } + public ushort Port { get; } + public RoomId RoomId { get; } + public SessionId SessionId { get; } + public long SessionGeneration { get; } + public PlatformUser LocalUser { get; } + public PlatformUser HostUser { get; } + public PlatformUser ExpectedRemoteUser { get; } + public CompatibilityDescriptor Compatibility { get; } + public string ProtocolVersion { get; } + public int MaxMembers { get; } + public RoomVisibility Visibility { get; } + public bool ExitOnReady { get; } + + public OperationResult Validate() + { + if (Role == LocalMultiplayerRole.None) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.local.role_missing"); + } + + if (Port == 0 || RoomId.IsEmpty || SessionId.IsEmpty || SessionGeneration < 1) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.local.session_descriptor_invalid"); + } + + if (LocalUser.Id.IsEmpty || HostUser.Id.IsEmpty || string.IsNullOrWhiteSpace(Address)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.local.identity_or_address_missing"); + } + + if (MaxMembers < 1 || string.IsNullOrWhiteSpace(ProtocolVersion)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.local.room_descriptor_invalid"); + } + + return CompatibilityValidator.ValidateDescriptor(Compatibility, ProtocolVersion); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs.meta new file mode 100644 index 00000000..3ded49c3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomOptions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fd4017e15ead49ef88b7ab57bd1df8e0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs new file mode 100644 index 00000000..6c9f4182 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs @@ -0,0 +1,408 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ZeroEngine.Multiplayer.Local +{ + public sealed class LocalDevelopmentRoomService : + IPlatformRoomService, + IRoomStatePublisher, + IRemoteConnectionAuthorizer + { + private readonly LocalDevelopmentRoomOptions _options; + private readonly List _members = new List(); + private INetworkConnectionDriver _driver; + private RoomSnapshot _currentRoom; + private bool _disposed; + + public LocalDevelopmentRoomService(LocalDevelopmentRoomOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public bool IsAvailable => !_disposed && _options.Validate().Succeeded; + public string UnavailableReasonKey => IsAvailable ? string.Empty : "multiplayer.local.unavailable"; + public PlatformUser LocalUser => _options.LocalUser; + public RoomSnapshot CurrentRoom => _currentRoom; + + public event Action RoomEvent; + public event Action JoinRequested; + + public void AttachConnectionDriver(INetworkConnectionDriver driver) + { + ThrowIfDisposed(); + if (ReferenceEquals(_driver, driver)) + { + return; + } + + if (_driver != null) + { + _driver.ConnectionEvent -= OnConnectionEvent; + } + + _driver = driver; + if (_driver != null) + { + _driver.ConnectionEvent += OnConnectionEvent; + } + } + + public Task> CreateAsync( + RoomCreateOptions options, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult(CancelledRoom()); + } + + if (_options.Role != LocalMultiplayerRole.Host) + { + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.local.create_requires_host_role")); + } + + OperationResult availability = _options.Validate(); + if (!availability.Succeeded) + { + return Task.FromResult(OperationResult.FromFailure(availability)); + } + + _members.Clear(); + _members.Add(new RoomMember(_options.LocalUser, MemberConnectionPhase.LobbyOnly, true, 0)); + _currentRoom = CreateSnapshot( + options.MaxMembers, + options.Visibility, + true, + options.ProtocolVersion, + options.Compatibility); + Raise(RoomEventType.RoomUpdated, default(PlatformUserId)); + return Task.FromResult(OperationResult.Success(_currentRoom)); + } + + public Task> JoinAsync( + RoomId roomId, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult(CancelledRoom()); + } + + if (_options.Role != LocalMultiplayerRole.Client) + { + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.local.join_requires_client_role")); + } + + if (roomId != _options.RoomId) + { + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.RoomNotFound, + "multiplayer.local.room_not_found", + roomId.Value)); + } + + OperationResult availability = _options.Validate(); + if (!availability.Succeeded) + { + return Task.FromResult(OperationResult.FromFailure(availability)); + } + + _members.Clear(); + _members.Add(new RoomMember(_options.HostUser, MemberConnectionPhase.LobbyOnly, true, 0)); + if (_options.LocalUser.Id != _options.HostUser.Id) + { + _members.Add(new RoomMember(_options.LocalUser, MemberConnectionPhase.LobbyOnly, false, 1)); + } + + _currentRoom = CreateSnapshot( + _options.MaxMembers, + _options.Visibility, + true, + _options.ProtocolVersion, + _options.Compatibility); + Raise(RoomEventType.RoomUpdated, default(PlatformUserId)); + return Task.FromResult(OperationResult.Success(_currentRoom)); + } + + public Task LeaveAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.cancelled")); + } + + RoomSnapshot previous = _currentRoom; + _currentRoom = null; + _members.Clear(); + if (previous != null) + { + Action handler = RoomEvent; + if (handler != null) + { + handler(new PlatformRoomEvent( + RoomEventType.Closed, + previous.Id, + previous.SessionGeneration, + null, + default(PlatformUserId))); + } + } + + return Task.FromResult(OperationResult.Success()); + } + + public OperationResult OpenInviteOverlay() + { + ThrowIfDisposed(); + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + "multiplayer.local.invite_overlay_unavailable"); + } + + public Task> RefreshAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult(CancelledRoom()); + } + + return Task.FromResult(_currentRoom == null + ? OperationResult.Failure( + MultiplayerErrorCode.RoomNotFound, + "multiplayer.local.no_current_room") + : OperationResult.Success(_currentRoom)); + } + + public OperationResult PublishRoomState(RoomSnapshot room) + { + ThrowIfDisposed(); + if (room == null || _currentRoom == null || room.Id != _currentRoom.Id || + !_currentRoom.IsHost(LocalUser.Id)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.local.state_update_requires_host"); + } + + _members.Clear(); + for (int i = 0; i < room.Members.Count; i++) + { + _members.Add(room.Members[i]); + } + + _currentRoom = room; + return OperationResult.Success(); + } + + public OperationResult AuthorizeRemoteUser(PlatformUserId userId) + { + ThrowIfDisposed(); + if (_options.Role != LocalMultiplayerRole.Host || _currentRoom == null) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.local.authorization_requires_host_room"); + } + + if (userId.IsEmpty || userId != _options.ExpectedRemoteUser.Id) + { + return OperationResult.Failure( + MultiplayerErrorCode.UnauthorizedPeer, + "multiplayer.local.remote_identity_rejected"); + } + + return OperationResult.Success(); + } + + public void SimulateJoinRequest(PlatformUser sender) + { + ThrowIfDisposed(); + Action handler = JoinRequested; + if (handler != null) + { + handler(new JoinRequest(_options.RoomId, sender)); + } + } + + public ValueTask DisposeAsync() + { + if (_disposed) + { + return default(ValueTask); + } + + _disposed = true; + if (_driver != null) + { + _driver.ConnectionEvent -= OnConnectionEvent; + _driver = null; + } + + _currentRoom = null; + _members.Clear(); + return default(ValueTask); + } + + private RoomSnapshot CreateSnapshot( + int maxMembers, + RoomVisibility visibility, + bool joinable, + string protocolVersion, + CompatibilityDescriptor compatibility) + { + return new RoomSnapshot( + _options.RoomId, + _options.HostUser.Id, + _members, + maxMembers, + visibility, + joinable, + compatibility.ProductId, + protocolVersion, + compatibility.GameProtocolVersion, + compatibility.ContentRevision, + compatibility.BuildVersion, + compatibility.GameRoomId, + _options.SessionId, + _options.SessionGeneration, + SessionPhase.InRoom, + false, + _options.Address); + } + + private void OnConnectionEvent(ConnectionEvent connectionEvent) + { + if (_currentRoom == null) + { + return; + } + + if (connectionEvent.Type == ConnectionEventType.PeerConnected) + { + AddOrUpdateRemoteMember(connectionEvent.UserId, MemberConnectionPhase.Connected); + } + else if (connectionEvent.Type == ConnectionEventType.PeerDisconnected) + { + AddOrUpdateRemoteMember(connectionEvent.UserId, MemberConnectionPhase.Disconnected); + Raise(RoomEventType.MemberLeft, connectionEvent.UserId); + } + else if (connectionEvent.Type == ConnectionEventType.ClientConnected) + { + SetMemberPhase(_options.LocalUser.Id, MemberConnectionPhase.Connected); + Raise(RoomEventType.RoomUpdated, _options.LocalUser.Id); + } + } + + private void AddOrUpdateRemoteMember(PlatformUserId userId, MemberConnectionPhase phase) + { + if (userId.IsEmpty) + { + return; + } + + int index = FindMember(userId); + bool added = index < 0; + if (added) + { + PlatformUser remote = userId == _options.ExpectedRemoteUser.Id + ? _options.ExpectedRemoteUser + : new PlatformUser(userId, userId.Value); + _members.Add(new RoomMember(remote, phase, false, _members.Count)); + } + else + { + _members[index] = _members[index].WithConnectionPhase(phase); + } + + RebuildSnapshot(); + Raise(added ? RoomEventType.MemberJoined : RoomEventType.RoomUpdated, userId); + } + + private void SetMemberPhase(PlatformUserId userId, MemberConnectionPhase phase) + { + int index = FindMember(userId); + if (index >= 0) + { + _members[index] = _members[index].WithConnectionPhase(phase); + RebuildSnapshot(); + } + } + + private int FindMember(PlatformUserId userId) + { + for (int i = 0; i < _members.Count; i++) + { + if (_members[i].User.Id == userId) + { + return i; + } + } + + return -1; + } + + private void RebuildSnapshot() + { + RoomSnapshot room = _currentRoom; + _currentRoom = new RoomSnapshot( + room.Id, + room.HostId, + _members, + room.MaxMembers, + room.Visibility, + room.IsJoinable, + room.ProductId, + room.ProtocolVersion, + room.GameProtocolVersion, + room.ContentRevision, + room.BuildVersion, + room.GameRoomId, + room.SessionId, + room.SessionGeneration, + room.Phase, + room.HasStarted, + room.ConnectionAddress); + } + + private void Raise(RoomEventType type, PlatformUserId memberId) + { + Action handler = RoomEvent; + if (handler != null && _currentRoom != null) + { + handler(new PlatformRoomEvent( + type, + _currentRoom.Id, + _currentRoom.SessionGeneration, + _currentRoom, + memberId)); + } + } + + private static OperationResult CancelledRoom() + { + return OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.cancelled"); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(LocalDevelopmentRoomService)); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs.meta new file mode 100644 index 00000000..dd4a87c4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalDevelopmentRoomService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 56d2dbad909a41babc3dce0e8718686c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs new file mode 100644 index 00000000..39591065 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace ZeroEngine.Multiplayer.Local +{ + public static class LocalMultiplayerLaunchArguments + { + public const string RoleOption = "--ze-multiplayer-role"; + public const string AddressOption = "--ze-multiplayer-address"; + public const string PortOption = "--ze-multiplayer-port"; + public const string RoomOption = "--ze-multiplayer-room"; + public const string SessionOption = "--ze-multiplayer-session"; + public const string GenerationOption = "--ze-multiplayer-generation"; + public const string LocalUserOption = "--ze-multiplayer-local-user"; + public const string LocalNameOption = "--ze-multiplayer-local-name"; + public const string HostUserOption = "--ze-multiplayer-host-user"; + public const string HostNameOption = "--ze-multiplayer-host-name"; + public const string RemoteUserOption = "--ze-multiplayer-remote-user"; + public const string RemoteNameOption = "--ze-multiplayer-remote-name"; + public const string ProtocolOption = "--ze-multiplayer-protocol"; + public const string ProductOption = "--ze-multiplayer-product"; + public const string GameProtocolOption = "--ze-multiplayer-game-protocol"; + public const string ContentOption = "--ze-multiplayer-content"; + public const string BuildOption = "--ze-multiplayer-build"; + public const string GameRoomOption = "--ze-multiplayer-game-room"; + public const string MaxMembersOption = "--ze-multiplayer-max-members"; + public const string ExitOnReadyOption = "--ze-multiplayer-exit-on-ready"; + + public static OperationResult Parse( + IReadOnlyList arguments, + CompatibilityDescriptor fallbackCompatibility, + string fallbackProtocolVersion, + ushort fallbackPort = 7770) + { + Dictionary values = ReadOptions(arguments); + LocalMultiplayerRole role; + if (!TryReadRole(Get(values, RoleOption), out role)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.local.argument_role_invalid"); + } + + ushort port = fallbackPort; + string portValue = Get(values, PortOption); + if (!string.IsNullOrEmpty(portValue) && + !ushort.TryParse(portValue, NumberStyles.None, CultureInfo.InvariantCulture, out port)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.local.argument_port_invalid", + portValue); + } + + long generation = 1; + string generationValue = Get(values, GenerationOption); + if (!string.IsNullOrEmpty(generationValue) && + (!long.TryParse(generationValue, NumberStyles.None, CultureInfo.InvariantCulture, out generation) || generation < 1)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.local.argument_generation_invalid", + generationValue); + } + + int maxMembers = 2; + string maxMembersValue = Get(values, MaxMembersOption); + if (!string.IsNullOrEmpty(maxMembersValue) && + (!int.TryParse(maxMembersValue, NumberStyles.None, CultureInfo.InvariantCulture, out maxMembers) || maxMembers < 1)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.local.argument_max_members_invalid", + maxMembersValue); + } + + string localUserDefault = role == LocalMultiplayerRole.Host ? "local-host" : "local-client"; + string localNameDefault = role == LocalMultiplayerRole.Host ? "Local Host" : "Local Client"; + PlatformUser localUser = new PlatformUser( + new PlatformUserId(DefaultIfEmpty(Get(values, LocalUserOption), localUserDefault)), + DefaultIfEmpty(Get(values, LocalNameOption), localNameDefault)); + PlatformUser hostUser = new PlatformUser( + new PlatformUserId(DefaultIfEmpty(Get(values, HostUserOption), "local-host")), + DefaultIfEmpty(Get(values, HostNameOption), "Local Host")); + PlatformUser expectedRemote = new PlatformUser( + new PlatformUserId(DefaultIfEmpty(Get(values, RemoteUserOption), "local-client")), + DefaultIfEmpty(Get(values, RemoteNameOption), "Local Client")); + + CompatibilityDescriptor compatibility = new CompatibilityDescriptor( + DefaultIfEmpty(Get(values, ProductOption), fallbackCompatibility.ProductId), + DefaultIfEmpty(Get(values, GameProtocolOption), fallbackCompatibility.GameProtocolVersion), + DefaultIfEmpty(Get(values, ContentOption), fallbackCompatibility.ContentRevision), + DefaultIfEmpty(Get(values, BuildOption), fallbackCompatibility.BuildVersion), + DefaultIfEmpty(Get(values, GameRoomOption), fallbackCompatibility.GameRoomId)); + + LocalDevelopmentRoomOptions options = new LocalDevelopmentRoomOptions( + role, + DefaultIfEmpty(Get(values, AddressOption), "127.0.0.1"), + port, + new RoomId(DefaultIfEmpty(Get(values, RoomOption), "local-room")), + new SessionId(DefaultIfEmpty(Get(values, SessionOption), "local-session")), + generation, + localUser, + hostUser, + expectedRemote, + compatibility, + DefaultIfEmpty(Get(values, ProtocolOption), fallbackProtocolVersion), + maxMembers, + RoomVisibility.Private, + values.ContainsKey(ExitOnReadyOption)); + + OperationResult validation = options.Validate(); + return validation.Succeeded + ? OperationResult.Success(options) + : OperationResult.FromFailure(validation); + } + + public static string Build(LocalDevelopmentRoomOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + List parts = new List(); + Add(parts, RoleOption, options.Role == LocalMultiplayerRole.Host ? "host" : "client"); + Add(parts, AddressOption, options.Address); + Add(parts, PortOption, options.Port.ToString(CultureInfo.InvariantCulture)); + Add(parts, RoomOption, options.RoomId.Value); + Add(parts, SessionOption, options.SessionId.Value); + Add(parts, GenerationOption, options.SessionGeneration.ToString(CultureInfo.InvariantCulture)); + Add(parts, LocalUserOption, options.LocalUser.Id.Value); + Add(parts, LocalNameOption, options.LocalUser.DisplayName); + Add(parts, HostUserOption, options.HostUser.Id.Value); + Add(parts, HostNameOption, options.HostUser.DisplayName); + Add(parts, RemoteUserOption, options.ExpectedRemoteUser.Id.Value); + Add(parts, RemoteNameOption, options.ExpectedRemoteUser.DisplayName); + Add(parts, ProtocolOption, options.ProtocolVersion); + Add(parts, ProductOption, options.Compatibility.ProductId); + Add(parts, GameProtocolOption, options.Compatibility.GameProtocolVersion); + Add(parts, ContentOption, options.Compatibility.ContentRevision); + Add(parts, BuildOption, options.Compatibility.BuildVersion); + Add(parts, GameRoomOption, options.Compatibility.GameRoomId); + Add(parts, MaxMembersOption, options.MaxMembers.ToString(CultureInfo.InvariantCulture)); + if (options.ExitOnReady) + { + parts.Add(ExitOnReadyOption); + } + + return string.Join(" ", parts); + } + + private static Dictionary ReadOptions(IReadOnlyList arguments) + { + Dictionary result = new Dictionary(StringComparer.Ordinal); + if (arguments == null) + { + return result; + } + + for (int i = 0; i < arguments.Count; i++) + { + string current = arguments[i]; + if (string.IsNullOrEmpty(current) || !current.StartsWith("--ze-multiplayer-", StringComparison.Ordinal)) + { + continue; + } + + if (string.Equals(current, ExitOnReadyOption, StringComparison.Ordinal)) + { + result[current] = string.Empty; + continue; + } + + if (i + 1 < arguments.Count) + { + result[current] = arguments[++i] ?? string.Empty; + } + } + + return result; + } + + private static bool TryReadRole(string value, out LocalMultiplayerRole role) + { + if (string.Equals(value, "host", StringComparison.OrdinalIgnoreCase)) + { + role = LocalMultiplayerRole.Host; + return true; + } + + if (string.Equals(value, "client", StringComparison.OrdinalIgnoreCase)) + { + role = LocalMultiplayerRole.Client; + return true; + } + + role = LocalMultiplayerRole.None; + return false; + } + + private static string Get(IDictionary values, string key) + { + string value; + return values.TryGetValue(key, out value) ? value : string.Empty; + } + + private static string DefaultIfEmpty(string value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback ?? string.Empty : value.Trim(); + } + + private static void Add(ICollection parts, string option, string value) + { + parts.Add(option); + parts.Add(Quote(value)); + } + + private static string Quote(string value) + { + value = value ?? string.Empty; + if (value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + { + return value; + } + + StringBuilder builder = new StringBuilder(value.Length + 2); + builder.Append('"'); + for (int i = 0; i < value.Length; i++) + { + if (value[i] == '"') + { + builder.Append('\\'); + } + + builder.Append(value[i]); + } + + builder.Append('"'); + return builder.ToString(); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs.meta new file mode 100644 index 00000000..ad83294a --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/LocalMultiplayerLaunchArguments.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6c8594ab94a4c069e55f157ca794fba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef new file mode 100644 index 00000000..ae8f5ac3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef @@ -0,0 +1,16 @@ +{ + "name": "ZeroEngine.Multiplayer.Local", + "rootNamespace": "ZeroEngine.Multiplayer.Local", + "references": [ + "ZeroEngine.Multiplayer.Core" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef.meta new file mode 100644 index 00000000..a1d1f621 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Local/ZeroEngine.Multiplayer.Local.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 478050dba1c64b2c8109b1abd11bfd89 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation.meta new file mode 100644 index 00000000..d8b62d69 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 909fe61b17006af42acf7bbf271b85c7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs new file mode 100644 index 00000000..e2a427db --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; + +namespace ZeroEngine.Multiplayer.Presentation +{ + public sealed class MultiplayerViewState + { + private MultiplayerViewState( + SessionPhase phase, + RoomSnapshot room, + bool canCreate, + bool canInvite, + bool canStart, + bool canLeave, + bool canRetry, + MultiplayerErrorCode errorCode, + string errorMessageKey, + IReadOnlyList errorArguments, + string progressMessageKey, + TimeSpan reconnectRemaining) + { + Phase = phase; + Room = room; + CanCreate = canCreate; + CanInvite = canInvite; + CanStart = canStart; + CanLeave = canLeave; + CanRetry = canRetry; + ErrorCode = errorCode; + ErrorMessageKey = errorMessageKey ?? string.Empty; + ErrorArguments = errorArguments ?? Array.Empty(); + ProgressMessageKey = progressMessageKey ?? string.Empty; + ReconnectRemaining = reconnectRemaining; + } + + public SessionPhase Phase { get; } + public RoomSnapshot Room { get; } + public bool CanCreate { get; } + public bool CanInvite { get; } + public bool CanStart { get; } + public bool CanLeave { get; } + public bool CanRetry { get; } + public MultiplayerErrorCode ErrorCode { get; } + public string ErrorMessageKey { get; } + public IReadOnlyList ErrorArguments { get; } + public string ProgressMessageKey { get; } + public TimeSpan ReconnectRemaining { get; } + + public static MultiplayerViewState Create( + MultiplayerSessionSnapshot snapshot, + MultiplayerSessionConfig config) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + RoomSnapshot room = snapshot.Room; + bool localIsHost = room != null && room.IsHost(snapshot.LocalUser.Id); + bool roomHasCapacity = room != null && room.Members.Count < room.MaxMembers; + bool canStart = !snapshot.OperationInProgress && snapshot.Phase == SessionPhase.Ready && + localIsHost && snapshot.IsServer && MeetsReadyConditions(room, config.MinimumPlayersToStart); + + return new MultiplayerViewState( + snapshot.Phase, + room, + !snapshot.OperationInProgress && snapshot.Phase == SessionPhase.Idle, + !snapshot.OperationInProgress && localIsHost && roomHasCapacity && + (snapshot.Phase == SessionPhase.InRoom || snapshot.Phase == SessionPhase.Ready), + canStart, + !snapshot.OperationInProgress && IsLeaveAvailable(snapshot.Phase, room), + !snapshot.OperationInProgress && snapshot.Phase == SessionPhase.Failed && + snapshot.RetryOperation != RetryOperationKind.None, + snapshot.LastResult.Succeeded ? MultiplayerErrorCode.None : snapshot.LastResult.ErrorCode, + snapshot.LastResult.Succeeded ? string.Empty : snapshot.LastResult.MessageKey, + snapshot.LastResult.Succeeded ? Array.Empty() : snapshot.LastResult.MessageArguments, + GetProgressMessageKey(snapshot.Phase), + snapshot.ReconnectRemaining); + } + + private static bool MeetsReadyConditions(RoomSnapshot room, int minimumPlayers) + { + if (room == null || room.Members.Count < minimumPlayers) + { + return false; + } + + for (int i = 0; i < room.Members.Count; i++) + { + if (room.Members[i].ConnectionPhase != MemberConnectionPhase.Ready) + { + return false; + } + } + + return true; + } + + private static bool IsLeaveAvailable(SessionPhase phase, RoomSnapshot room) + { + if (room == null && phase != SessionPhase.Failed) + { + return false; + } + + return phase == SessionPhase.InRoom || phase == SessionPhase.Ready || + phase == SessionPhase.InGame || phase == SessionPhase.Recovery || + phase == SessionPhase.Failed; + } + + private static string GetProgressMessageKey(SessionPhase phase) + { + switch (phase) + { + case SessionPhase.Initializing: + return "multiplayer.progress.initializing"; + case SessionPhase.CreatingRoom: + return "multiplayer.progress.creating_room"; + case SessionPhase.JoiningRoom: + return "multiplayer.progress.joining_room"; + case SessionPhase.Connecting: + return "multiplayer.progress.connecting"; + case SessionPhase.Synchronizing: + return "multiplayer.progress.synchronizing"; + case SessionPhase.Starting: + return "multiplayer.progress.starting"; + case SessionPhase.Reconnecting: + return "multiplayer.progress.reconnecting"; + case SessionPhase.Leaving: + return "multiplayer.progress.leaving"; + default: + return string.Empty; + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs.meta new file mode 100644 index 00000000..579c89d5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e4f2c2bb525b5334eab40a7e69dee916 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef new file mode 100644 index 00000000..c6e883f4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef @@ -0,0 +1,16 @@ +{ + "name": "ZeroEngine.Multiplayer.Presentation", + "rootNamespace": "ZeroEngine.Multiplayer.Presentation", + "references": [ + "ZeroEngine.Multiplayer.Core" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef.meta new file mode 100644 index 00000000..7d87ec27 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/ZeroEngine.Multiplayer.Presentation.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a7b4d9d0e2541814d9c426b8db2d980e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam.meta new file mode 100644 index 00000000..0985b50e --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d5fe84f75b784667b4d775d16e86d69d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs new file mode 100644 index 00000000..c5255bf4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs @@ -0,0 +1,10 @@ +namespace ZeroEngine.Multiplayer.Steam +{ + public interface ISteamRuntime + { + bool IsAvailable { get; } + string UnavailableReasonKey { get; } + PlatformUser LocalUser { get; } + OperationResult EnsureInitialized(); + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs.meta new file mode 100644 index 00000000..05930745 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ISteamRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 96fdcb144c7c432bb3b1db003cc78854 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs new file mode 100644 index 00000000..df787007 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs @@ -0,0 +1,55 @@ +using System; +using Steamworks; + +namespace ZeroEngine.Multiplayer.Steam +{ + public sealed class SteamInviteRouter : IDisposable + { + private readonly Callback _joinRequested; + private bool _disposed; + + public SteamInviteRouter(ISteamRuntime runtime) + { + if (runtime == null) + { + throw new ArgumentNullException(nameof(runtime)); + } + + _joinRequested = Callback.Create(OnJoinRequested); + } + + public event Action JoinRequested; + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _joinRequested.Dispose(); + } + + private void OnJoinRequested(GameLobbyJoinRequested_t callback) + { + if (_disposed || !callback.m_steamIDLobby.IsValid()) + { + return; + } + + PlatformUser sender = new PlatformUser( + new PlatformUserId(callback.m_steamIDFriend.m_SteamID.ToString()), + callback.m_steamIDFriend.IsValid() + ? SteamFriends.GetFriendPersonaName(callback.m_steamIDFriend) + : string.Empty); + Action handler = JoinRequested; + if (handler != null) + { + handler(new JoinRequest( + new RoomId(callback.m_steamIDLobby.m_SteamID.ToString()), + sender)); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs.meta new file mode 100644 index 00000000..91b2525a --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamInviteRouter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 71e853d9d4a0446390688a5aa8a4fc7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs new file mode 100644 index 00000000..1c247a76 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace ZeroEngine.Multiplayer.Steam +{ + public readonly struct SteamRoomDescriptor + { + public SteamRoomDescriptor( + string productId, + string protocolVersion, + string gameProtocolVersion, + string contentRevision, + string buildVersion, + string gameRoomId, + SessionId sessionId, + long sessionGeneration, + SessionPhase phase, + PlatformUserId hostId, + RoomVisibility visibility, + bool isJoinable, + bool hasStarted) + { + ProductId = productId; + ProtocolVersion = protocolVersion; + GameProtocolVersion = gameProtocolVersion; + ContentRevision = contentRevision; + BuildVersion = buildVersion; + GameRoomId = gameRoomId; + SessionId = sessionId; + SessionGeneration = sessionGeneration; + Phase = phase; + HostId = hostId; + Visibility = visibility; + IsJoinable = isJoinable; + HasStarted = hasStarted; + } + + public string ProductId { get; } + public string ProtocolVersion { get; } + public string GameProtocolVersion { get; } + public string ContentRevision { get; } + public string BuildVersion { get; } + public string GameRoomId { get; } + public SessionId SessionId { get; } + public long SessionGeneration { get; } + public SessionPhase Phase { get; } + public PlatformUserId HostId { get; } + public RoomVisibility Visibility { get; } + public bool IsJoinable { get; } + public bool HasStarted { get; } + } + + public static class SteamRoomMetadata + { + private const int MaximumValueLength = 255; + + public static IReadOnlyDictionary Create( + string prefix, + RoomCreateOptions options, + SessionId sessionId, + long sessionGeneration, + PlatformUserId hostId) + { + string normalizedPrefix = NormalizePrefix(prefix); + Dictionary values = new Dictionary(StringComparer.Ordinal) + { + [normalizedPrefix + "product"] = options.Compatibility.ProductId, + [normalizedPrefix + "protocol"] = options.ProtocolVersion, + [normalizedPrefix + "game_protocol"] = options.Compatibility.GameProtocolVersion, + [normalizedPrefix + "content"] = options.Compatibility.ContentRevision, + [normalizedPrefix + "build"] = options.Compatibility.BuildVersion, + [normalizedPrefix + "room"] = options.Compatibility.GameRoomId, + [normalizedPrefix + "session"] = sessionId.Value, + [normalizedPrefix + "generation"] = sessionGeneration.ToString(CultureInfo.InvariantCulture), + [normalizedPrefix + "state"] = "waiting", + [normalizedPrefix + "host"] = hostId.Value, + [normalizedPrefix + "visibility"] = VisibilityToString(options.Visibility), + [normalizedPrefix + "joinable"] = "1" + }; + return values; + } + + public static OperationResult Read( + string prefix, + Func readValue) + { + if (readValue == null) + { + throw new ArgumentNullException(nameof(readValue)); + } + + string normalizedPrefix = NormalizePrefix(prefix); + string product = ReadRequired(normalizedPrefix, "product", readValue); + string protocol = ReadRequired(normalizedPrefix, "protocol", readValue); + string gameProtocol = ReadRequired(normalizedPrefix, "game_protocol", readValue); + string content = ReadRequired(normalizedPrefix, "content", readValue); + string build = ReadRequired(normalizedPrefix, "build", readValue); + string room = ReadRequired(normalizedPrefix, "room", readValue); + string session = ReadRequired(normalizedPrefix, "session", readValue); + string generationValue = ReadRequired(normalizedPrefix, "generation", readValue); + string state = ReadRequired(normalizedPrefix, "state", readValue); + string host = ReadRequired(normalizedPrefix, "host", readValue); + string visibility = ReadRequired(normalizedPrefix, "visibility", readValue); + string joinableValue = ReadRequired(normalizedPrefix, "joinable", readValue); + + if (string.IsNullOrWhiteSpace(product) || string.IsNullOrWhiteSpace(protocol) || + string.IsNullOrWhiteSpace(gameProtocol) || string.IsNullOrWhiteSpace(content) || + string.IsNullOrWhiteSpace(build) || string.IsNullOrWhiteSpace(room) || + string.IsNullOrWhiteSpace(session) || string.IsNullOrWhiteSpace(host) || + (joinableValue != "0" && joinableValue != "1")) + { + return OperationResult.Failure( + MultiplayerErrorCode.JoinFailed, + "multiplayer.steam.metadata_missing"); + } + + long generation; + if (!long.TryParse(generationValue, NumberStyles.None, CultureInfo.InvariantCulture, out generation) || generation < 1) + { + return OperationResult.Failure( + MultiplayerErrorCode.SessionMismatch, + "multiplayer.steam.metadata_generation_invalid"); + } + + SessionPhase phase; + bool started; + bool stateJoinable; + if (!TryReadState(state, out phase, out started, out stateJoinable)) + { + return OperationResult.Failure( + MultiplayerErrorCode.JoinFailed, + "multiplayer.steam.metadata_state_invalid", + state); + } + + RoomVisibility roomVisibility; + if (!TryReadVisibility(visibility, out roomVisibility)) + { + return OperationResult.Failure( + MultiplayerErrorCode.JoinFailed, + "multiplayer.steam.metadata_visibility_invalid", + visibility); + } + + bool explicitJoinable = joinableValue == "1"; + SteamRoomDescriptor descriptor = new SteamRoomDescriptor( + product, + protocol, + gameProtocol, + content, + build, + room, + new SessionId(session), + generation, + phase, + new PlatformUserId(host), + roomVisibility, + stateJoinable && explicitJoinable, + started); + return OperationResult.Success(descriptor); + } + + public static string NormalizePrefix(string prefix) + { + string value = string.IsNullOrWhiteSpace(prefix) ? "ze_" : prefix.Trim(); + return value.EndsWith("_", StringComparison.Ordinal) ? value : value + "_"; + } + + public static OperationResult ValidateValues(IReadOnlyDictionary values) + { + if (values == null || values.Count == 0) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.steam.metadata_empty"); + } + + foreach (KeyValuePair pair in values) + { + if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value == null || pair.Value.Length > MaximumValueLength) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.steam.metadata_value_invalid", + pair.Key ?? string.Empty); + } + } + + return OperationResult.Success(); + } + + private static string ReadRequired(string prefix, string key, Func readValue) + { + return readValue(prefix + key) ?? string.Empty; + } + + private static string VisibilityToString(RoomVisibility visibility) + { + if (visibility == RoomVisibility.Public) + { + return "public"; + } + + return visibility == RoomVisibility.FriendsOnly ? "friends" : "private"; + } + + private static bool TryReadVisibility(string value, out RoomVisibility visibility) + { + if (string.Equals(value, "public", StringComparison.Ordinal)) + { + visibility = RoomVisibility.Public; + return true; + } + + if (string.Equals(value, "friends", StringComparison.Ordinal)) + { + visibility = RoomVisibility.FriendsOnly; + return true; + } + + if (string.Equals(value, "private", StringComparison.Ordinal)) + { + visibility = RoomVisibility.Private; + return true; + } + + visibility = RoomVisibility.Private; + return false; + } + + private static bool TryReadState( + string value, + out SessionPhase phase, + out bool started, + out bool joinable) + { + started = false; + joinable = true; + if (string.Equals(value, "waiting", StringComparison.Ordinal)) + { + phase = SessionPhase.InRoom; + return true; + } + + if (string.Equals(value, "connecting", StringComparison.Ordinal)) + { + phase = SessionPhase.Connecting; + return true; + } + + if (string.Equals(value, "ready", StringComparison.Ordinal)) + { + phase = SessionPhase.Ready; + return true; + } + + if (string.Equals(value, "starting", StringComparison.Ordinal)) + { + phase = SessionPhase.Starting; + joinable = false; + return true; + } + + if (string.Equals(value, "ingame", StringComparison.Ordinal)) + { + phase = SessionPhase.InGame; + started = true; + return true; + } + + if (string.Equals(value, "closed", StringComparison.Ordinal)) + { + phase = SessionPhase.Recovery; + joinable = false; + return true; + } + + phase = SessionPhase.Failed; + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs.meta new file mode 100644 index 00000000..2ad7d505 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomMetadata.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: feb30c06b6ac43239baae42a4fba1ad3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs new file mode 100644 index 00000000..0a3215de --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs @@ -0,0 +1,829 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Steamworks; + +namespace ZeroEngine.Multiplayer.Steam +{ + public sealed class SteamRoomService : + IPlatformRoomService, + IRoomStatePublisher, + IRemoteConnectionAuthorizer + { + private readonly ISteamRuntime _runtime; + private readonly string _metadataPrefix; + private readonly CallResult _lobbyCreated; + private readonly CallResult _lobbyEntered; + private readonly Callback _lobbyChatUpdated; + private readonly Callback _lobbyDataUpdated; + private readonly SteamInviteRouter _inviteRouter; + + private TaskCompletionSource> _createCompletion; + private TaskCompletionSource> _joinCompletion; + private TaskCompletionSource> _refreshCompletion; + private RoomCreateOptions _pendingCreateOptions; + private SessionId _pendingSessionId; + private RoomSnapshot _currentRoom; + private CSteamID _currentLobby = CSteamID.Nil; + private bool _createCallInFlight; + private bool _joinCallInFlight; + private bool _refreshInFlight; + private bool _disposed; + + public SteamRoomService(ISteamRuntime runtime, string metadataPrefix) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _metadataPrefix = SteamRoomMetadata.NormalizePrefix(metadataPrefix); + + _lobbyCreated = CallResult.Create(OnLobbyCreated); + _lobbyEntered = CallResult.Create(OnLobbyEntered); + _lobbyChatUpdated = Callback.Create(OnLobbyChatUpdated); + _lobbyDataUpdated = Callback.Create(OnLobbyDataUpdated); + _inviteRouter = new SteamInviteRouter(_runtime); + _inviteRouter.JoinRequested += OnJoinRequested; + } + + public bool IsAvailable => !_disposed && _runtime.IsAvailable; + public string UnavailableReasonKey => IsAvailable ? string.Empty : _runtime.UnavailableReasonKey; + public PlatformUser LocalUser => _runtime.LocalUser; + public RoomSnapshot CurrentRoom => _currentRoom; + + public event Action RoomEvent; + public event Action JoinRequested; + + public async Task> CreateAsync( + RoomCreateOptions options, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return CancelledRoom(); + } + + OperationResult available = EnsureAvailable(); + if (!available.Succeeded) + { + return OperationResult.FromFailure(available); + } + + if (HasPendingOperation()) + { + return OperationResult.Failure( + MultiplayerErrorCode.Busy, + "multiplayer.steam.operation_busy"); + } + + _pendingSessionId = new SessionId(Guid.NewGuid().ToString("N")); + IReadOnlyDictionary metadata = SteamRoomMetadata.Create( + _metadataPrefix, + options, + _pendingSessionId, + 1, + LocalUser.Id); + OperationResult metadataValidation = SteamRoomMetadata.ValidateValues(metadata); + if (!metadataValidation.Succeeded) + { + return OperationResult.FromFailure(metadataValidation); + } + + _pendingCreateOptions = options; + _createCompletion = NewCompletion(); + _createCallInFlight = true; + try + { + _lobbyCreated.Set(SteamMatchmaking.CreateLobby( + ToLobbyType(options.Visibility), + options.MaxMembers)); + } + catch + { + _createCompletion = null; + _createCallInFlight = false; + throw; + } + + Task> task = _createCompletion.Task; + using (cancellationToken.Register(() => + _createCompletion?.TrySetResult(CancelledRoom()))) + { + OperationResult result = await task; + _createCompletion = null; + return result; + } + } + + public async Task> JoinAsync( + RoomId roomId, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return CancelledRoom(); + } + + OperationResult available = EnsureAvailable(); + if (!available.Succeeded) + { + return OperationResult.FromFailure(available); + } + + if (HasPendingOperation()) + { + return OperationResult.Failure( + MultiplayerErrorCode.Busy, + "multiplayer.steam.operation_busy"); + } + + ulong lobbyValue; + if (roomId.IsEmpty || !ulong.TryParse(roomId.Value, out lobbyValue) || lobbyValue == 0) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.steam.room_id_invalid", + roomId.Value); + } + + CSteamID lobby = new CSteamID(lobbyValue); + if (!lobby.IsValid()) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.steam.room_id_invalid", + roomId.Value); + } + + _joinCompletion = NewCompletion(); + _joinCallInFlight = true; + try + { + _lobbyEntered.Set(SteamMatchmaking.JoinLobby(lobby)); + } + catch + { + _joinCompletion = null; + _joinCallInFlight = false; + throw; + } + Task> task = _joinCompletion.Task; + using (cancellationToken.Register(() => + _joinCompletion?.TrySetResult(CancelledRoom()))) + { + OperationResult result = await task; + _joinCompletion = null; + return result; + } + } + + public Task LeaveAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.cancelled")); + } + + if (HasPendingOperation()) + { + return Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.Busy, + "multiplayer.steam.operation_busy")); + } + + RoomSnapshot previous = _currentRoom; + OperationResult available = EnsureAvailable(); + if (!available.Succeeded) + { + _currentLobby = CSteamID.Nil; + _currentRoom = null; + return Task.FromResult(available); + } + + if (_currentLobby.IsValid()) + { + if (SteamMatchmaking.GetLobbyOwner(_currentLobby).m_SteamID == ParseUserId(LocalUser.Id)) + { + SteamMatchmaking.SetLobbyData(_currentLobby, Key("state"), "closed"); + SteamMatchmaking.SetLobbyData(_currentLobby, Key("joinable"), "0"); + SteamMatchmaking.SetLobbyJoinable(_currentLobby, false); + } + + SteamMatchmaking.LeaveLobby(_currentLobby); + } + + _currentLobby = CSteamID.Nil; + _currentRoom = null; + if (previous != null) + { + Raise(RoomEventType.Closed, previous, default(PlatformUserId)); + } + + return Task.FromResult(OperationResult.Success()); + } + + public OperationResult OpenInviteOverlay() + { + ThrowIfDisposed(); + OperationResult available = EnsureAvailable(); + if (!available.Succeeded) + { + return available; + } + + if (!_currentLobby.IsValid()) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.steam.invite_requires_room"); + } + + SteamFriends.ActivateGameOverlayInviteDialog(_currentLobby); + return OperationResult.Success(); + } + + public async Task> RefreshAsync( + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + { + return CancelledRoom(); + } + + OperationResult available = EnsureAvailable(); + if (!available.Succeeded) + { + return OperationResult.FromFailure(available); + } + + if (!_currentLobby.IsValid()) + { + return OperationResult.Failure( + MultiplayerErrorCode.RoomNotFound, + "multiplayer.steam.no_current_room"); + } + + if (HasPendingOperation()) + { + return OperationResult.Failure( + MultiplayerErrorCode.Busy, + "multiplayer.steam.refresh_busy"); + } + + _refreshCompletion = NewCompletion(); + _refreshInFlight = true; + bool requested; + try + { + requested = SteamMatchmaking.RequestLobbyData(_currentLobby); + } + catch + { + _refreshCompletion = null; + _refreshInFlight = false; + throw; + } + + if (!requested) + { + _refreshCompletion = null; + _refreshInFlight = false; + return OperationResult.Failure( + MultiplayerErrorCode.RoomNotFound, + "multiplayer.steam.refresh_rejected"); + } + + Task> task = _refreshCompletion.Task; + using (cancellationToken.Register(() => + _refreshCompletion?.TrySetResult(CancelledRoom()))) + { + OperationResult result = await task; + _refreshCompletion = null; + return result; + } + } + + public OperationResult PublishRoomState(RoomSnapshot room) + { + ThrowIfDisposed(); + OperationResult available = EnsureAvailable(); + if (!available.Succeeded) + { + return available; + } + + if (room == null || !_currentLobby.IsValid() || _currentRoom == null || + room.Id != _currentRoom.Id || + SteamMatchmaking.GetLobbyOwner(_currentLobby).m_SteamID != ParseUserId(LocalUser.Id)) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.steam.state_update_requires_host"); + } + + string state = ToMetadataState(room.Phase); + bool generationWritten = SteamMatchmaking.SetLobbyData( + _currentLobby, + Key("generation"), + room.SessionGeneration.ToString(CultureInfo.InvariantCulture)); + bool stateWritten = SteamMatchmaking.SetLobbyData(_currentLobby, Key("state"), state); + bool joinableWritten = SteamMatchmaking.SetLobbyData( + _currentLobby, + Key("joinable"), + room.IsJoinable ? "1" : "0"); + bool lobbyUpdated = SteamMatchmaking.SetLobbyJoinable(_currentLobby, room.IsJoinable); + if (!generationWritten || !stateWritten || !joinableWritten || !lobbyUpdated) + { + return OperationResult.Failure( + MultiplayerErrorCode.CreateFailed, + "multiplayer.steam.state_update_failed"); + } + + _currentRoom = room; + return OperationResult.Success(); + } + + public OperationResult AuthorizeRemoteUser(PlatformUserId userId) + { + ThrowIfDisposed(); + OperationResult available = EnsureAvailable(); + if (!available.Succeeded) + { + return available; + } + + if (userId.IsEmpty || !_currentLobby.IsValid() || _currentRoom == null || + !_currentRoom.IsHost(LocalUser.Id)) + { + return OperationResult.Failure( + MultiplayerErrorCode.UnauthorizedPeer, + "multiplayer.steam.authorization_requires_host_room"); + } + + bool wasKnown = false; + bool wasDisconnected = false; + for (int i = 0; i < _currentRoom.Members.Count; i++) + { + RoomMember member = _currentRoom.Members[i]; + if (member.User.Id == userId) + { + wasKnown = true; + wasDisconnected = member.ConnectionPhase == MemberConnectionPhase.Disconnected; + break; + } + } + + OperationResult current = BuildSnapshot(_currentLobby); + if (!current.Succeeded) + { + return current.Result; + } + + RoomSnapshot snapshot = current.Value; + bool isMember = false; + for (int i = 0; i < snapshot.Members.Count; i++) + { + if (snapshot.Members[i].User.Id == userId) + { + isMember = true; + break; + } + } + + if (!isMember) + { + return OperationResult.Failure( + MultiplayerErrorCode.UnauthorizedPeer, + "multiplayer.steam.remote_not_in_lobby"); + } + + if (snapshot.Members.Count > snapshot.MaxMembers) + { + return OperationResult.Failure( + MultiplayerErrorCode.RoomFull, + "multiplayer.steam.room_over_capacity"); + } + + if (!snapshot.IsJoinable && !wasDisconnected) + { + return OperationResult.Failure( + MultiplayerErrorCode.RoomStarted, + "multiplayer.steam.room_not_joinable"); + } + + _currentRoom = snapshot; + Raise( + wasKnown ? RoomEventType.RoomUpdated : RoomEventType.MemberJoined, + snapshot, + userId); + return OperationResult.Success(); + } + + public ValueTask DisposeAsync() + { + if (_disposed) + { + return default(ValueTask); + } + + if (_currentLobby.IsValid() && _runtime.IsAvailable) + { + try + { + SteamMatchmaking.LeaveLobby(_currentLobby); + } + catch + { + // Disposal must not throw after the Steam runtime has begun shutting down. + } + } + + _disposed = true; + _createCompletion?.TrySetResult(CancelledRoom()); + _joinCompletion?.TrySetResult(CancelledRoom()); + _refreshCompletion?.TrySetResult(CancelledRoom()); + _createCompletion = null; + _joinCompletion = null; + _refreshCompletion = null; + _createCallInFlight = false; + _joinCallInFlight = false; + _refreshInFlight = false; + _currentLobby = CSteamID.Nil; + _currentRoom = null; + _inviteRouter.JoinRequested -= OnJoinRequested; + _inviteRouter.Dispose(); + _lobbyCreated.Dispose(); + _lobbyEntered.Dispose(); + _lobbyChatUpdated.Dispose(); + _lobbyDataUpdated.Dispose(); + return default(ValueTask); + } + + private void OnLobbyCreated(LobbyCreated_t callback, bool ioFailure) + { + try + { + TaskCompletionSource> completion = _createCompletion; + CSteamID lobby = new CSteamID(callback.m_ulSteamIDLobby); + if (completion == null || completion.Task.IsCompleted) + { + if (!ioFailure && callback.m_eResult == EResult.k_EResultOK && lobby.IsValid()) + { + SteamMatchmaking.LeaveLobby(lobby); + } + + return; + } + + if (ioFailure || callback.m_eResult != EResult.k_EResultOK || !lobby.IsValid()) + { + completion.TrySetResult(OperationResult.Failure( + MultiplayerErrorCode.CreateFailed, + "multiplayer.steam.create_failed", + callback.m_eResult.ToString())); + return; + } + + IReadOnlyDictionary metadata = SteamRoomMetadata.Create( + _metadataPrefix, + _pendingCreateOptions, + _pendingSessionId, + 1, + LocalUser.Id); + foreach (KeyValuePair pair in metadata) + { + if (!SteamMatchmaking.SetLobbyData(lobby, pair.Key, pair.Value)) + { + SteamMatchmaking.LeaveLobby(lobby); + completion.TrySetResult(OperationResult.Failure( + MultiplayerErrorCode.CreateFailed, + "multiplayer.steam.metadata_write_failed", + pair.Key)); + return; + } + } + + SteamMatchmaking.SetLobbyJoinable(lobby, true); + _currentLobby = lobby; + OperationResult snapshot = BuildSnapshot(lobby); + if (snapshot.Succeeded) + { + _currentRoom = snapshot.Value; + Raise(RoomEventType.RoomUpdated, _currentRoom, default(PlatformUserId)); + } + + completion.TrySetResult(snapshot); + } + finally + { + _createCallInFlight = false; + } + } + + private void OnLobbyEntered(LobbyEnter_t callback, bool ioFailure) + { + try + { + TaskCompletionSource> completion = _joinCompletion; + CSteamID lobby = new CSteamID(callback.m_ulSteamIDLobby); + EChatRoomEnterResponse response = (EChatRoomEnterResponse)callback.m_EChatRoomEnterResponse; + if (completion == null || completion.Task.IsCompleted) + { + if (!ioFailure && response == EChatRoomEnterResponse.k_EChatRoomEnterResponseSuccess && + lobby.IsValid()) + { + SteamMatchmaking.LeaveLobby(lobby); + } + + return; + } + + if (ioFailure || response != EChatRoomEnterResponse.k_EChatRoomEnterResponseSuccess || !lobby.IsValid()) + { + completion.TrySetResult(OperationResult.Failure( + response == EChatRoomEnterResponse.k_EChatRoomEnterResponseFull + ? MultiplayerErrorCode.RoomFull + : MultiplayerErrorCode.JoinFailed, + "multiplayer.steam.join_failed", + response.ToString())); + return; + } + + _currentLobby = lobby; + OperationResult snapshot = BuildSnapshot(lobby); + if (!snapshot.Succeeded) + { + SteamMatchmaking.LeaveLobby(lobby); + _currentLobby = CSteamID.Nil; + } + else + { + _currentRoom = snapshot.Value; + Raise(RoomEventType.RoomUpdated, _currentRoom, LocalUser.Id); + } + + completion.TrySetResult(snapshot); + } + finally + { + _joinCallInFlight = false; + } + } + + private void OnLobbyChatUpdated(LobbyChatUpdate_t callback) + { + if (!_currentLobby.IsValid() || callback.m_ulSteamIDLobby != _currentLobby.m_SteamID) + { + return; + } + + PlatformUserId changed = new PlatformUserId( + callback.m_ulSteamIDUserChanged.ToString(CultureInfo.InvariantCulture)); + EChatMemberStateChange change = (EChatMemberStateChange)callback.m_rgfChatMemberStateChange; + bool entered = (change & EChatMemberStateChange.k_EChatMemberStateChangeEntered) != 0; + bool left = (change & (EChatMemberStateChange.k_EChatMemberStateChangeLeft | + EChatMemberStateChange.k_EChatMemberStateChangeDisconnected | + EChatMemberStateChange.k_EChatMemberStateChangeKicked | + EChatMemberStateChange.k_EChatMemberStateChangeBanned)) != 0; + + RoomSnapshot previous = _currentRoom; + if (left && previous != null && changed == previous.HostId) + { + Raise(RoomEventType.HostLeft, previous, changed); + return; + } + + OperationResult snapshot = BuildSnapshot(_currentLobby); + if (!snapshot.Succeeded) + { + return; + } + + _currentRoom = snapshot.Value; + Raise( + entered ? RoomEventType.MemberJoined : left ? RoomEventType.MemberLeft : RoomEventType.RoomUpdated, + _currentRoom, + changed); + } + + private void OnLobbyDataUpdated(LobbyDataUpdate_t callback) + { + if (!_currentLobby.IsValid() || callback.m_ulSteamIDLobby != _currentLobby.m_SteamID) + { + return; + } + + if (callback.m_bSuccess == 0) + { + _refreshInFlight = false; + _refreshCompletion?.TrySetResult(OperationResult.Failure( + MultiplayerErrorCode.RoomNotFound, + "multiplayer.steam.refresh_failed")); + return; + } + + OperationResult snapshot = BuildSnapshot(_currentLobby); + if (!snapshot.Succeeded) + { + _refreshInFlight = false; + _refreshCompletion?.TrySetResult(snapshot); + return; + } + + _currentRoom = snapshot.Value; + _refreshInFlight = false; + Raise(RoomEventType.DataChanged, _currentRoom, default(PlatformUserId)); + _refreshCompletion?.TrySetResult(snapshot); + } + + private OperationResult BuildSnapshot(CSteamID lobby) + { + OperationResult metadata = SteamRoomMetadata.Read( + _metadataPrefix, + key => SteamMatchmaking.GetLobbyData(lobby, key)); + if (!metadata.Succeeded) + { + return OperationResult.FromFailure(metadata.Result); + } + + SteamRoomDescriptor descriptor = metadata.Value; + CSteamID lobbyOwner = SteamMatchmaking.GetLobbyOwner(lobby); + if (!lobbyOwner.IsValid() || lobbyOwner.m_SteamID.ToString() != descriptor.HostId.Value) + { + return OperationResult.Failure( + MultiplayerErrorCode.HostLeft, + "multiplayer.steam.host_identity_mismatch"); + } + + int memberCount = SteamMatchmaking.GetNumLobbyMembers(lobby); + List members = new List(memberCount); + for (int i = 0; i < memberCount; i++) + { + CSteamID memberId = SteamMatchmaking.GetLobbyMemberByIndex(lobby, i); + PlatformUserId id = new PlatformUserId(memberId.m_SteamID.ToString()); + string displayName = SteamFriends.GetFriendPersonaName(memberId); + MemberConnectionPhase phase = FindExistingMemberPhase(id); + members.Add(new RoomMember( + new PlatformUser(id, displayName), + phase, + id == descriptor.HostId, + i)); + } + + RoomSnapshot snapshot = new RoomSnapshot( + new RoomId(lobby.m_SteamID.ToString()), + descriptor.HostId, + members, + SteamMatchmaking.GetLobbyMemberLimit(lobby), + descriptor.Visibility, + descriptor.IsJoinable, + descriptor.ProductId, + descriptor.ProtocolVersion, + descriptor.GameProtocolVersion, + descriptor.ContentRevision, + descriptor.BuildVersion, + descriptor.GameRoomId, + descriptor.SessionId, + descriptor.SessionGeneration, + descriptor.Phase, + descriptor.HasStarted, + descriptor.HostId.Value); + return OperationResult.Success(snapshot); + } + + private MemberConnectionPhase FindExistingMemberPhase(PlatformUserId userId) + { + if (_currentRoom != null) + { + for (int i = 0; i < _currentRoom.Members.Count; i++) + { + if (_currentRoom.Members[i].User.Id == userId) + { + return _currentRoom.Members[i].ConnectionPhase; + } + } + } + + return MemberConnectionPhase.LobbyOnly; + } + + private void OnJoinRequested(JoinRequest request) + { + Action handler = JoinRequested; + if (handler != null) + { + handler(request); + } + } + + private OperationResult EnsureAvailable() + { + if (!_runtime.IsAvailable) + { + OperationResult initialized = _runtime.EnsureInitialized(); + if (!initialized.Succeeded) + { + return initialized; + } + } + + return OperationResult.Success(); + } + + private bool HasPendingOperation() + { + return _createCompletion != null || _joinCompletion != null || _refreshCompletion != null || + _createCallInFlight || _joinCallInFlight || _refreshInFlight; + } + + private string Key(string suffix) + { + return _metadataPrefix + suffix; + } + + private void Raise(RoomEventType type, RoomSnapshot snapshot, PlatformUserId memberId) + { + Action handler = RoomEvent; + if (handler != null && snapshot != null) + { + handler(new PlatformRoomEvent( + type, + snapshot.Id, + snapshot.SessionGeneration, + snapshot, + memberId)); + } + } + + private OperationResult CancelledRoom() + { + return OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.cancelled"); + } + + private static TaskCompletionSource> NewCompletion() + { + return new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + } + + private static ELobbyType ToLobbyType(RoomVisibility visibility) + { + if (visibility == RoomVisibility.Public) + { + return ELobbyType.k_ELobbyTypePublic; + } + + return visibility == RoomVisibility.FriendsOnly + ? ELobbyType.k_ELobbyTypeFriendsOnly + : ELobbyType.k_ELobbyTypePrivate; + } + + private static string ToMetadataState(SessionPhase phase) + { + if (phase == SessionPhase.Connecting || phase == SessionPhase.Synchronizing) + { + return "connecting"; + } + + if (phase == SessionPhase.Ready) + { + return "ready"; + } + + if (phase == SessionPhase.Starting) + { + return "starting"; + } + + if (phase == SessionPhase.InGame) + { + return "ingame"; + } + + return phase == SessionPhase.Recovery || phase == SessionPhase.Failed + ? "closed" + : "waiting"; + } + + private static ulong ParseUserId(PlatformUserId userId) + { + ulong value; + return ulong.TryParse(userId.Value, out value) ? value : 0; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(SteamRoomService)); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs.meta new file mode 100644 index 00000000..7881d2ee --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRoomService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7e744db213ee4f1991f2f7f0c3e00b37 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs new file mode 100644 index 00000000..57b66363 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs @@ -0,0 +1,168 @@ +using System; +using Steamworks; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.Steam +{ + [DefaultExecutionOrder(-32000)] + [DisallowMultipleComponent] + public sealed class SteamRuntimeOwner : MonoBehaviour, ISteamRuntime + { + [SerializeField] private bool initializeOnAwake = true; + [SerializeField] private bool persistAcrossScenes = true; + [SerializeField] private bool restartAppIfNecessary = true; + [SerializeField] private uint appId = 480; + + private static SteamRuntimeOwner _activeOwner; + + private bool _initialized; + private bool _ownsRuntime; + private string _unavailableReasonKey = "multiplayer.steam.not_initialized"; + private PlatformUser _localUser; + + public static SteamRuntimeOwner ActiveOwner => _activeOwner; + public bool IsAvailable => _initialized && _ownsRuntime; + public string UnavailableReasonKey => IsAvailable ? string.Empty : _unavailableReasonKey; + public PlatformUser LocalUser => _localUser; + public bool OwnsRuntime => _ownsRuntime; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStatics() + { + _activeOwner = null; + } + + private void Awake() + { + if (_activeOwner != null && _activeOwner != this) + { + _unavailableReasonKey = "multiplayer.steam.duplicate_runtime_owner"; + enabled = false; + Debug.LogError( + "[ZeroEngine.Multiplayer] A second SteamRuntimeOwner was rejected. Steam may only be initialized and pumped by one owner.", + this); + return; + } + + _activeOwner = this; + _ownsRuntime = true; + if (persistAcrossScenes) + { + DontDestroyOnLoad(gameObject); + } + + if (initializeOnAwake) + { + OperationResult result = EnsureInitialized(); + if (!result.Succeeded) + { + Debug.LogWarning( + "[ZeroEngine.Multiplayer] Steam runtime unavailable: " + result.MessageKey, + this); + } + } + } + + private void Update() + { + if (_initialized && _ownsRuntime) + { + SteamAPI.RunCallbacks(); + } + } + + private void OnDestroy() + { + if (_activeOwner != this) + { + return; + } + + if (_initialized && _ownsRuntime) + { + SteamAPI.Shutdown(); + } + + _initialized = false; + _ownsRuntime = false; + _activeOwner = null; + } + + public OperationResult EnsureInitialized() + { + if (_initialized && _ownsRuntime) + { + return OperationResult.Success(); + } + + if (_activeOwner != this || !_ownsRuntime) + { + _unavailableReasonKey = "multiplayer.steam.runtime_not_owner"; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + _unavailableReasonKey); + } + + try + { + if (!Packsize.Test() || !DllCheck.Test()) + { + _unavailableReasonKey = "multiplayer.steam.binary_validation_failed"; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + _unavailableReasonKey); + } + + if (!Application.isEditor && restartAppIfNecessary && + SteamAPI.RestartAppIfNecessary(new AppId_t(appId))) + { + _unavailableReasonKey = "multiplayer.steam.restart_requested"; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + _unavailableReasonKey); + } + + if (!SteamAPI.Init()) + { + _unavailableReasonKey = "multiplayer.steam.initialization_failed"; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + _unavailableReasonKey); + } + + CSteamID steamId = SteamUser.GetSteamID(); + if (!steamId.IsValid()) + { + SteamAPI.Shutdown(); + _unavailableReasonKey = "multiplayer.steam.local_user_invalid"; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + _unavailableReasonKey); + } + + _initialized = true; + _localUser = new PlatformUser( + new PlatformUserId(steamId.m_SteamID.ToString()), + SteamFriends.GetPersonaName()); + _unavailableReasonKey = string.Empty; + return OperationResult.Success(); + } + catch (DllNotFoundException exception) + { + _unavailableReasonKey = "multiplayer.steam.library_missing"; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + _unavailableReasonKey, + exception.GetType().Name); + } + catch (Exception exception) + { + _unavailableReasonKey = "multiplayer.steam.initialization_exception"; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + _unavailableReasonKey, + exception.GetType().Name); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs.meta new file mode 100644 index 00000000..627c0612 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75c210ed3a11405b83f174b0d55a6b3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef new file mode 100644 index 00000000..d340edfc --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef @@ -0,0 +1,31 @@ +{ + "name": "ZeroEngine.Multiplayer.Steam", + "rootNamespace": "ZeroEngine.Multiplayer.Steam", + "references": [ + "ZeroEngine.Multiplayer.Core", + "com.rlabrecque.steamworks.net" + ], + "includePlatforms": [ + "Editor", + "LinuxStandalone64", + "macOSStandalone", + "WindowsStandalone32", + "WindowsStandalone64" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "ZEROENGINE_MULTIPLAYER_STEAMWORKS" + ], + "versionDefines": [ + { + "name": "com.rlabrecque.steamworks.net", + "expression": "[2024.8.0,2025.0.0)", + "define": "ZEROENGINE_MULTIPLAYER_STEAMWORKS" + } + ], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef.meta new file mode 100644 index 00000000..e627aa0f --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/ZeroEngine.Multiplayer.Steam.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: db743d247d3b42baa42f08ff3300968f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI.meta b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI.meta new file mode 100644 index 00000000..f797342e --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 53582ec0379549c5bd9e59ab61e2bff7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs new file mode 100644 index 00000000..017a3938 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs @@ -0,0 +1,284 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FishNet.Managing; +using FishNet.Transporting; +using UnityEngine; +using ZeroEngine.Multiplayer.FishNet; +using ZeroEngine.Multiplayer.Local; + +namespace ZeroEngine.Multiplayer.Samples +{ + public sealed class LocalRoomSampleController : MonoBehaviour, IMultiplayerGameAdapter + { + [SerializeField] private MultiplayerSessionConfig sessionConfig; + [SerializeField] private NetworkManager networkManager; + [SerializeField] private Transport transport; + [SerializeField] private FishNetIdentityBridge identityBridge; + [SerializeField] private FishNetConnectionDriver connectionDriver; + [SerializeField] private MultiplayerBootstrap bootstrap; + [SerializeField] private bool autoStartFromCommandLine = true; + + private LocalDevelopmentRoomOptions _options; + private LocalDevelopmentRoomService _roomService; + private CancellationTokenSource _lifetime; + private string _status = "Idle"; + private bool _readyLogged; + private bool _starting; + + private async void Start() + { + ResolveReferences(); + _lifetime = new CancellationTokenSource(); + if (autoStartFromCommandLine) + { + await StartFromCommandLineAsync(_lifetime.Token); + } + } + + private void OnDestroy() + { + if (_lifetime != null) + { + _lifetime.Cancel(); + _lifetime.Dispose(); + _lifetime = null; + } + + if (connectionDriver != null) + { + connectionDriver.ConnectionEvent -= OnConnectionEvent; + } + } + + private void OnGUI() + { + GUILayout.BeginArea(new Rect(20f, 20f, 520f, 220f), GUI.skin.box); + GUILayout.Label("ZeroEngine Multiplayer — LocalDirect Sample"); + GUILayout.Label("Status: " + _status); + if (_options != null) + { + GUILayout.Label("Role: " + _options.Role); + GUILayout.Label("Room: " + _options.RoomId.Value + " " + _options.Address + ":" + _options.Port); + } + + if (bootstrap != null && bootstrap.Coordinator != null) + { + GUILayout.Label("Phase: " + bootstrap.Coordinator.Phase); + if (GUILayout.Button("Leave")) + { + _ = bootstrap.Coordinator.LeaveAsync(CancellationToken.None); + } + } + GUILayout.EndArea(); + } + + public CompatibilityDescriptor GetCompatibility() + { + return _options == null + ? FallbackCompatibility() + : _options.Compatibility; + } + + public Task PrepareSessionAsync( + MultiplayerSessionContext context, + CancellationToken cancellationToken) + { + return Task.FromResult(OperationResult.Success()); + } + + public Task SynchronizePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken) + { + return Task.FromResult(OperationResult.Success()); + } + + public Task RestorePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken) + { + return Task.FromResult(OperationResult.Success()); + } + + public void OnPeerDisconnected(MultiplayerPeer peer, TimeSpan gracePeriod) + { + } + + public void OnPeerReconnectExpired(MultiplayerPeer peer) + { + } + + public void OnSessionEnded(SessionEndReason reason) + { + } + + private async Task StartFromCommandLineAsync(CancellationToken cancellationToken) + { + if (_starting) + { + return; + } + + _starting = true; + OperationResult parsed = LocalMultiplayerLaunchArguments.Parse( + Environment.GetCommandLineArgs(), + FallbackCompatibility(), + sessionConfig == null ? "1" : sessionConfig.ProtocolVersion, + sessionConfig == null ? (ushort)7770 : (ushort)sessionConfig.LocalPort); + if (!parsed.Succeeded) + { + _status = parsed.MessageKey; + Debug.LogError("[ZeroEngine.Multiplayer.Sample] " + _status, this); + return; + } + + _options = parsed.Value; + if (sessionConfig == null || networkManager == null || transport == null || + identityBridge == null || connectionDriver == null || bootstrap == null) + { + _status = "Sample scene references are incomplete."; + Debug.LogError("[ZeroEngine.Multiplayer.Sample] " + _status, this); + return; + } + + identityBridge.Configure(TransportMode.LocalDirect, _options.ExpectedRemoteUser.Id.Value); + connectionDriver.Configure( + networkManager, + transport, + identityBridge, + TransportMode.LocalDirect, + _options.Address, + _options.Port); + OperationResult driverSetup = connectionDriver.ValidateSetup(); + if (!driverSetup.Succeeded) + { + _status = driverSetup.MessageKey; + Debug.LogError("[ZeroEngine.Multiplayer.Sample] " + _status, this); + return; + } + + _roomService = new LocalDevelopmentRoomService(_options); + _roomService.AttachConnectionDriver(connectionDriver); + bootstrap.Configure(sessionConfig, _roomService, connectionDriver, this); + connectionDriver.ConnectionEvent += OnConnectionEvent; + + OperationResult initialized = await bootstrap.InitializeAsync(cancellationToken); + if (!initialized.Succeeded) + { + _status = initialized.MessageKey; + return; + } + + OperationResult room = _options.Role == LocalMultiplayerRole.Host + ? await bootstrap.Coordinator.CreateRoomAsync(cancellationToken) + : await bootstrap.Coordinator.JoinRoomAsync(_options.RoomId, cancellationToken); + if (!room.Succeeded) + { + _status = room.MessageKey; + Debug.LogError("[ZeroEngine.Multiplayer.Sample] " + _status, this); + return; + } + + if (_options.Role == LocalMultiplayerRole.Client) + { + OperationResult synchronized = bootstrap.Coordinator.ConfirmLocalSynchronization( + room.Value.SessionId, + room.Value.SessionGeneration); + if (!synchronized.Succeeded) + { + _status = synchronized.MessageKey; + return; + } + } + + ObserveReady(); + } + + private async void OnConnectionEvent(ConnectionEvent connectionEvent) + { + if (_options == null || _options.Role != LocalMultiplayerRole.Host || + connectionEvent.Type != ConnectionEventType.PeerConnected || + bootstrap == null || bootstrap.Coordinator == null) + { + return; + } + + try + { + PlatformUser peerUser = connectionEvent.UserId == _options.ExpectedRemoteUser.Id + ? _options.ExpectedRemoteUser + : new PlatformUser(connectionEvent.UserId, connectionEvent.UserId.Value); + OperationResult synchronized = await bootstrap.Coordinator.SynchronizePeerAsync( + new MultiplayerPeer(peerUser, 1), + _lifetime == null ? CancellationToken.None : _lifetime.Token); + if (!synchronized.Succeeded) + { + _status = synchronized.MessageKey; + return; + } + + ObserveReady(); + } + catch (OperationCanceledException) + { + } + } + + private async void ObserveReady() + { + if (_readyLogged || bootstrap == null || bootstrap.Coordinator == null || + bootstrap.Coordinator.Phase != SessionPhase.Ready) + { + return; + } + + _readyLogged = true; + _status = "Ready"; + Debug.Log("ZEROENGINE_M2_READY role=" + _options.Role.ToString().ToLowerInvariant(), this); + if (_options.ExitOnReady) + { + await Task.Delay(1000); + Application.Quit(0); + } + } + + private void ResolveReferences() + { + if (networkManager == null) + { + networkManager = GetComponent(); + } + + if (networkManager != null && transport == null && networkManager.TransportManager != null) + { + transport = networkManager.TransportManager.Transport; + } + + if (identityBridge == null) + { + identityBridge = GetComponent(); + } + + if (connectionDriver == null) + { + connectionDriver = GetComponent(); + } + + if (bootstrap == null) + { + bootstrap = GetComponent(); + } + } + + private static CompatibilityDescriptor FallbackCompatibility() + { + return new CompatibilityDescriptor( + "zeroengine-sample", + "1", + "sample", + "development", + "sample-room"); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs.meta new file mode 100644 index 00000000..cbfc5b92 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0670a222b0284a3aa3d8c4174595c471 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md new file mode 100644 index 00000000..4dea5afa --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md @@ -0,0 +1,15 @@ +# LocalDirect Room UI sample + +This sample is intentionally visual-light: it uses `OnGUI` so it can validate a blank room without importing a UI framework. + +## Scene setup + +1. Install the pinned FishNet dependency listed in the package root README. +2. Create one GameObject and add `NetworkManager`, `Tugboat`, `FishNetIdentityBridge`, `FishNetConnectionDriver`, `MultiplayerBootstrap`, and `LocalRoomSampleController`. +3. Assign a valid `MultiplayerSessionConfig` to the sample controller. The remaining references resolve from the same GameObject. +4. Put the scene in Build Settings and build a Windows player. +5. Open **Window > ZeroEngine > Multiplayer > Local Launcher**, select the player, and choose **Launch Both**. + +`MultiplayerBootstrap` wires the room service as the driver's remote authorizer. The host and client logs each emit `ZEROENGINE_M2_READY` after FishNet authentication, LocalDirect identity admission, and the sample synchronization acknowledgement complete. + +`LocalDirect` identities are explicit development identities. They are not suitable for release authorization. diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md.meta b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md.meta new file mode 100644 index 00000000..99fe56da --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a7528e7c75974a7c84ebb32003e6c444 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef new file mode 100644 index 00000000..e9231ab5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef @@ -0,0 +1,27 @@ +{ + "name": "ZeroEngine.Multiplayer.Samples.RoomUI", + "rootNamespace": "ZeroEngine.Multiplayer.Samples", + "references": [ + "ZeroEngine.Multiplayer.Core", + "ZeroEngine.Multiplayer.Local", + "ZeroEngine.Multiplayer.FishNet", + "FishNet.Runtime" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "ZEROENGINE_MULTIPLAYER_FISHNET_SAMPLE" + ], + "versionDefines": [ + { + "name": "com.firstgeargames.fishnet", + "expression": "[4.6.15,5.0.0)", + "define": "ZEROENGINE_MULTIPLAYER_FISHNET_SAMPLE" + } + ], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef.meta new file mode 100644 index 00000000..7eb45cc2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/ZeroEngine.Multiplayer.Samples.RoomUI.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 30f9f69bb25e4558b16919f9be5c2257 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json new file mode 100644 index 00000000..4c74dc9d --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json @@ -0,0 +1,4 @@ +{ + "displayName": "LocalDirect Room UI", + "description": "Minimal FishNet LocalDirect host/client room UI and readiness sample." +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json.meta b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json.meta new file mode 100644 index 00000000..bacd960d --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b37ab2fe0da0483eaba3cd0629729bca +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests.meta new file mode 100644 index 00000000..08e59cb4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9c4f03fbf317a2b4a8112bc857068998 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor.meta new file mode 100644 index 00000000..2ff4dd64 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0af6886bf61125b429623c9e9a481965 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs new file mode 100644 index 00000000..5fc0bece --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; + +namespace ZeroEngine.Multiplayer.Tests +{ + public sealed class CompatibilityValidatorTests + { + [Test] + public void MatchingRoom_IsCompatible() + { + OperationResult result = CompatibilityValidator.Validate( + TestData.CreateRoom(TestData.Guest), + TestData.Compatibility, + "1", + BuildMatchPolicy.Exact); + + Assert.IsTrue(result.Succeeded); + } + + [TestCase("product", MultiplayerErrorCode.ProductMismatch)] + [TestCase("protocol", MultiplayerErrorCode.ProtocolMismatch)] + [TestCase("gameProtocol", MultiplayerErrorCode.GameProtocolMismatch)] + [TestCase("content", MultiplayerErrorCode.ContentMismatch)] + [TestCase("build", MultiplayerErrorCode.BuildMismatch)] + [TestCase("gameRoom", MultiplayerErrorCode.GameRoomMismatch)] + public void Mismatch_ReturnsSpecificError(string field, MultiplayerErrorCode expected) + { + RoomSnapshot room = TestData.CreateRoom( + TestData.Guest, + productId: field == "product" ? "other" : "test-product", + protocolVersion: field == "protocol" ? "other" : "1", + gameProtocolVersion: field == "gameProtocol" ? "other" : "game-1", + contentRevision: field == "content" ? "other" : "content-1", + buildVersion: field == "build" ? "other" : "build-1", + gameRoomId: field == "gameRoom" ? "other" : "gallery-room"); + + OperationResult result = CompatibilityValidator.Validate( + room, + TestData.Compatibility, + "1", + BuildMatchPolicy.Exact); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(expected, result.ErrorCode); + } + + [Test] + public void IgnoreBuildPolicy_AllowsBuildDifferenceOnly() + { + RoomSnapshot room = TestData.CreateRoom(TestData.Guest, buildVersion: "other-build"); + + OperationResult result = CompatibilityValidator.Validate( + room, + TestData.Compatibility, + "1", + BuildMatchPolicy.Ignore); + + Assert.IsTrue(result.Succeeded); + } + + [Test] + public void IncompleteLocalDescriptor_IsInvalidConfiguration() + { + CompatibilityDescriptor incomplete = new CompatibilityDescriptor( + string.Empty, + "game-1", + "content-1", + "build-1", + "gallery-room"); + + OperationResult result = CompatibilityValidator.ValidateDescriptor(incomplete, "1"); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.InvalidConfiguration, result.ErrorCode); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs.meta new file mode 100644 index 00000000..0c774677 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/CompatibilityValidatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 867027340e8a4a24abc3b1b4c2fc9590 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs new file mode 100644 index 00000000..86564482 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using NUnit.Framework; +using ZeroEngine.Multiplayer.Local; + +namespace ZeroEngine.Multiplayer.Tests +{ + public sealed class LocalDevelopmentRoomServiceTests + { + [Test] + public void HostCreate_ProducesSteamShapedDescriptorWithConnectionAddress() + { + LocalDevelopmentRoomOptions options = CreateOptions(LocalMultiplayerRole.Host); + LocalDevelopmentRoomService service = new LocalDevelopmentRoomService(options); + RoomCreateOptions create = new RoomCreateOptions( + RoomVisibility.Private, + 2, + false, + options.ProtocolVersion, + options.Compatibility); + + OperationResult result = service.CreateAsync(create, CancellationToken.None) + .GetAwaiter().GetResult(); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Value.Id, Is.EqualTo(options.RoomId)); + Assert.That(result.Value.SessionId, Is.EqualTo(options.SessionId)); + Assert.That(result.Value.ConnectionAddress, Is.EqualTo("127.0.0.1")); + Assert.That(result.Value.Members.Count, Is.EqualTo(1)); + Assert.That(result.Value.Members[0].IsHost, Is.True); + } + + [Test] + public void ClientJoin_ProducesHostAndLocalMember() + { + LocalDevelopmentRoomOptions options = CreateOptions(LocalMultiplayerRole.Client); + LocalDevelopmentRoomService service = new LocalDevelopmentRoomService(options); + + OperationResult result = service.JoinAsync(options.RoomId, CancellationToken.None) + .GetAwaiter().GetResult(); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Value.HostId, Is.EqualTo(options.HostUser.Id)); + Assert.That(result.Value.Members.Count, Is.EqualTo(2)); + Assert.That(result.Value.Members[1].User.Id, Is.EqualTo(options.LocalUser.Id)); + } + + [Test] + public void ClientJoin_DifferentRoomFailsAsRoomNotFound() + { + LocalDevelopmentRoomOptions options = CreateOptions(LocalMultiplayerRole.Client); + LocalDevelopmentRoomService service = new LocalDevelopmentRoomService(options); + + OperationResult result = service.JoinAsync( + new RoomId("different"), + CancellationToken.None) + .GetAwaiter().GetResult(); + + Assert.That(result.Succeeded, Is.False); + Assert.That(result.ErrorCode, Is.EqualTo(MultiplayerErrorCode.RoomNotFound)); + } + + [Test] + public void AttachedDriver_PeerConnectedAddsMemberAndRaisesEvent() + { + LocalDevelopmentRoomOptions options = CreateOptions(LocalMultiplayerRole.Host); + LocalDevelopmentRoomService service = new LocalDevelopmentRoomService(options); + FakeConnectionDriver driver = new FakeConnectionDriver(); + service.AttachConnectionDriver(driver); + service.CreateAsync( + new RoomCreateOptions( + RoomVisibility.Private, + 2, + false, + options.ProtocolVersion, + options.Compatibility), + CancellationToken.None) + .GetAwaiter().GetResult(); + List events = new List(); + service.RoomEvent += events.Add; + + driver.Emit(new ConnectionEvent( + ConnectionEventType.PeerConnected, + options.ExpectedRemoteUser.Id, + MultiplayerErrorCode.None, + string.Empty)); + + Assert.That(service.CurrentRoom.Members.Count, Is.EqualTo(2)); + Assert.That(service.CurrentRoom.Members[1].ConnectionPhase, Is.EqualTo(MemberConnectionPhase.Connected)); + Assert.That(events.Exists(item => item.Type == RoomEventType.MemberJoined), Is.True); + } + + [Test] + public void HostAuthorization_OnlyAcceptsConfiguredRemoteIdentity() + { + LocalDevelopmentRoomOptions options = CreateOptions(LocalMultiplayerRole.Host); + LocalDevelopmentRoomService service = new LocalDevelopmentRoomService(options); + service.CreateAsync( + new RoomCreateOptions( + RoomVisibility.Private, + 2, + false, + options.ProtocolVersion, + options.Compatibility), + CancellationToken.None) + .GetAwaiter().GetResult(); + + OperationResult accepted = service.AuthorizeRemoteUser(options.ExpectedRemoteUser.Id); + OperationResult rejected = service.AuthorizeRemoteUser(new PlatformUserId("intruder")); + + Assert.That(accepted.Succeeded, Is.True); + Assert.That(rejected.ErrorCode, Is.EqualTo(MultiplayerErrorCode.UnauthorizedPeer)); + } + + [Test] + public void Leave_ClearsCurrentRoom() + { + LocalDevelopmentRoomOptions options = CreateOptions(LocalMultiplayerRole.Host); + LocalDevelopmentRoomService service = new LocalDevelopmentRoomService(options); + service.CreateAsync( + new RoomCreateOptions( + RoomVisibility.Private, + 2, + false, + options.ProtocolVersion, + options.Compatibility), + CancellationToken.None) + .GetAwaiter().GetResult(); + + OperationResult result = service.LeaveAsync(CancellationToken.None) + .GetAwaiter().GetResult(); + + Assert.That(result.Succeeded, Is.True); + Assert.That(service.CurrentRoom, Is.Null); + } + + private static LocalDevelopmentRoomOptions CreateOptions(LocalMultiplayerRole role) + { + PlatformUser host = new PlatformUser(new PlatformUserId("local-host"), "Host"); + PlatformUser client = new PlatformUser(new PlatformUserId("local-client"), "Client"); + return new LocalDevelopmentRoomOptions( + role, + "127.0.0.1", + 7770, + new RoomId("local-room"), + new SessionId("local-session"), + 1, + role == LocalMultiplayerRole.Host ? host : client, + host, + role == LocalMultiplayerRole.Host ? client : host, + TestData.Compatibility, + "1", + 2, + RoomVisibility.Private); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs.meta new file mode 100644 index 00000000..9a84014a --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalDevelopmentRoomServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fec1945147ca409a931c3e7e9fb91009 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs new file mode 100644 index 00000000..67afa242 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs @@ -0,0 +1,78 @@ +using NUnit.Framework; +using ZeroEngine.Multiplayer.Local; + +namespace ZeroEngine.Multiplayer.Tests +{ + public sealed class LocalMultiplayerLaunchArgumentsTests + { + [Test] + public void Parse_ExplicitClientArgumentsBuildsDescriptor() + { + string[] arguments = + { + "player.exe", + LocalMultiplayerLaunchArguments.RoleOption, "client", + LocalMultiplayerLaunchArguments.AddressOption, "192.168.1.20", + LocalMultiplayerLaunchArguments.PortOption, "7788", + LocalMultiplayerLaunchArguments.RoomOption, "room-a", + LocalMultiplayerLaunchArguments.SessionOption, "session-a", + LocalMultiplayerLaunchArguments.LocalNameOption, "Player Two", + LocalMultiplayerLaunchArguments.ExitOnReadyOption + }; + + OperationResult result = LocalMultiplayerLaunchArguments.Parse( + arguments, + TestData.Compatibility, + "1"); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Value.Role, Is.EqualTo(LocalMultiplayerRole.Client)); + Assert.That(result.Value.Address, Is.EqualTo("192.168.1.20")); + Assert.That(result.Value.Port, Is.EqualTo(7788)); + Assert.That(result.Value.RoomId.Value, Is.EqualTo("room-a")); + Assert.That(result.Value.LocalUser.DisplayName, Is.EqualTo("Player Two")); + Assert.That(result.Value.ExitOnReady, Is.True); + } + + [Test] + public void Parse_MissingRoleFailsStably() + { + OperationResult result = LocalMultiplayerLaunchArguments.Parse( + new[] { "player.exe" }, + TestData.Compatibility, + "1"); + + Assert.That(result.Succeeded, Is.False); + Assert.That(result.ErrorCode, Is.EqualTo(MultiplayerErrorCode.InvalidArgument)); + Assert.That(result.MessageKey, Is.EqualTo("multiplayer.local.argument_role_invalid")); + } + + [Test] + public void Build_QuotesDisplayNamesAndIncludesCompatibility() + { + PlatformUser host = new PlatformUser(new PlatformUserId("local-host"), "Local Host"); + PlatformUser client = new PlatformUser(new PlatformUserId("local-client"), "Local Client"); + LocalDevelopmentRoomOptions options = new LocalDevelopmentRoomOptions( + LocalMultiplayerRole.Host, + "127.0.0.1", + 7770, + new RoomId("local-room"), + new SessionId("local-session"), + 1, + host, + host, + client, + TestData.Compatibility, + "1", + 2, + RoomVisibility.Private, + true); + + string result = LocalMultiplayerLaunchArguments.Build(options); + + StringAssert.Contains("--ze-multiplayer-local-name \"Local Host\"", result); + StringAssert.Contains("--ze-multiplayer-product test-product", result); + StringAssert.Contains(LocalMultiplayerLaunchArguments.ExitOnReadyOption, result); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs.meta new file mode 100644 index 00000000..6c05f388 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/LocalMultiplayerLaunchArgumentsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e984a2c8c0d846a5b5477a377ab7b39f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs new file mode 100644 index 00000000..7421c678 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ZeroEngine.Multiplayer.Tests +{ + internal sealed class FakePlatformRoomService : IPlatformRoomService, IRoomStatePublisher + { + public bool IsAvailable { get; set; } = true; + public string UnavailableReasonKey { get; set; } = string.Empty; + public PlatformUser LocalUser { get; set; } = new PlatformUser(new PlatformUserId("local"), "Local"); + public RoomSnapshot CurrentRoom { get; private set; } + public OperationResult CreateResult { get; set; } + public OperationResult JoinResult { get; set; } + public OperationResult LeaveResult { get; set; } = OperationResult.Success(); + public OperationResult InviteResult { get; set; } = OperationResult.Success(); + public OperationResult PublishResult { get; set; } = OperationResult.Success(); + public Func>> CreateHandler { get; set; } + public Func>> JoinHandler { get; set; } + public int CreateCalls { get; private set; } + public int JoinCalls { get; private set; } + public int LeaveCalls { get; private set; } + public int DisposeCalls { get; private set; } + public int PublishCalls { get; private set; } + public List PublishedRooms { get; } = new List(); + + public event Action RoomEvent; + public event Action JoinRequested; + + public async Task> CreateAsync( + RoomCreateOptions options, + CancellationToken cancellationToken) + { + CreateCalls++; + OperationResult result = CreateHandler == null + ? CreateResult + : await CreateHandler(options, cancellationToken); + if (result.Succeeded) + { + CurrentRoom = result.Value; + } + + return result; + } + + public async Task> JoinAsync( + RoomId roomId, + CancellationToken cancellationToken) + { + JoinCalls++; + OperationResult result = JoinHandler == null + ? JoinResult + : await JoinHandler(roomId, cancellationToken); + if (result.Succeeded) + { + CurrentRoom = result.Value; + } + + return result; + } + + public Task LeaveAsync(CancellationToken cancellationToken) + { + LeaveCalls++; + CurrentRoom = null; + return Task.FromResult(LeaveResult); + } + + public OperationResult OpenInviteOverlay() + { + return InviteResult; + } + + public Task> RefreshAsync(CancellationToken cancellationToken) + { + return CurrentRoom == null + ? Task.FromResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "test.no_room")) + : Task.FromResult(OperationResult.Success(CurrentRoom)); + } + + public OperationResult PublishRoomState(RoomSnapshot room) + { + PublishCalls++; + PublishedRooms.Add(room); + if (PublishResult.Succeeded) + { + CurrentRoom = room; + } + + return PublishResult; + } + + public ValueTask DisposeAsync() + { + DisposeCalls++; + return default(ValueTask); + } + + public void EmitRoomEvent(PlatformRoomEvent roomEvent) + { + Action handler = RoomEvent; + if (handler != null) + { + handler(roomEvent); + } + } + + public void EmitJoinRequest(JoinRequest request) + { + Action handler = JoinRequested; + if (handler != null) + { + handler(request); + } + } + } + + internal sealed class FakeConnectionDriver : INetworkConnectionDriver + { + public ConnectionPhase Phase { get; private set; } = ConnectionPhase.Stopped; + public bool IsServer { get; private set; } + public bool IsClient { get; private set; } + public OperationResult StartHostResult { get; set; } = OperationResult.Success(); + public OperationResult StartClientResult { get; set; } = OperationResult.Success(); + public OperationResult StopResult { get; set; } = OperationResult.Success(); + public bool EmitDisconnectOnStop { get; set; } + public int StartHostCalls { get; private set; } + public int StartClientCalls { get; private set; } + public int StopCalls { get; private set; } + + public event Action ConnectionEvent; + + public Task StartHostAsync( + HostConnectionOptions options, + CancellationToken cancellationToken) + { + StartHostCalls++; + if (StartHostResult.Succeeded) + { + Phase = ConnectionPhase.Hosting; + IsServer = true; + IsClient = true; + } + else + { + Phase = ConnectionPhase.Failed; + } + + return Task.FromResult(StartHostResult); + } + + public Task StartClientAsync( + ClientConnectionOptions options, + CancellationToken cancellationToken) + { + StartClientCalls++; + if (StartClientResult.Succeeded) + { + Phase = ConnectionPhase.Connected; + IsClient = true; + IsServer = false; + } + else + { + Phase = ConnectionPhase.Failed; + } + + return Task.FromResult(StartClientResult); + } + + public Task StopAsync( + DisconnectIntent intent, + CancellationToken cancellationToken) + { + StopCalls++; + if (EmitDisconnectOnStop) + { + Emit(new ConnectionEvent( + ConnectionEventType.LocalDisconnected, + default(PlatformUserId), + MultiplayerErrorCode.TransportFailed, + "test.disconnect_on_stop")); + } + + Phase = StopResult.Succeeded ? ConnectionPhase.Stopped : ConnectionPhase.Failed; + IsServer = false; + IsClient = false; + return Task.FromResult(StopResult); + } + + public void Emit(ConnectionEvent connectionEvent) + { + Action handler = ConnectionEvent; + if (handler != null) + { + handler(connectionEvent); + } + } + } + + internal sealed class FakeGameAdapter : IMultiplayerGameAdapter + { + private readonly Queue _prepareResults = new Queue(); + + public CompatibilityDescriptor Compatibility { get; set; } = TestData.Compatibility; + public OperationResult DefaultPrepareResult { get; set; } = OperationResult.Success(); + public OperationResult SynchronizeResult { get; set; } = OperationResult.Success(); + public OperationResult RestoreResult { get; set; } = OperationResult.Success(); + public int PrepareCalls { get; private set; } + public int SynchronizeCalls { get; private set; } + public int RestoreCalls { get; private set; } + public int PeerDisconnectedCalls { get; private set; } + public int ReconnectExpiredCalls { get; private set; } + public int SessionEndedCalls { get; private set; } + + public CompatibilityDescriptor GetCompatibility() + { + return Compatibility; + } + + public Task PrepareSessionAsync( + MultiplayerSessionContext context, + CancellationToken cancellationToken) + { + PrepareCalls++; + return Task.FromResult(_prepareResults.Count > 0 + ? _prepareResults.Dequeue() + : DefaultPrepareResult); + } + + public Task SynchronizePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken) + { + SynchronizeCalls++; + return Task.FromResult(SynchronizeResult); + } + + public Task RestorePeerAsync( + MultiplayerPeer peer, + CancellationToken cancellationToken) + { + RestoreCalls++; + return Task.FromResult(RestoreResult); + } + + public void OnPeerDisconnected(MultiplayerPeer peer, TimeSpan gracePeriod) + { + PeerDisconnectedCalls++; + } + + public void OnPeerReconnectExpired(MultiplayerPeer peer) + { + ReconnectExpiredCalls++; + } + + public void OnSessionEnded(SessionEndReason reason) + { + SessionEndedCalls++; + } + + public void EnqueuePrepare(OperationResult result) + { + _prepareResults.Enqueue(result); + } + } + + internal static class TestData + { + public static readonly PlatformUser Host = new PlatformUser(new PlatformUserId("host"), "Host"); + public static readonly PlatformUser Guest = new PlatformUser(new PlatformUserId("guest"), "Guest"); + public static readonly CompatibilityDescriptor Compatibility = new CompatibilityDescriptor( + "test-product", + "game-1", + "content-1", + "build-1", + "gallery-room"); + + public static RoomSnapshot CreateRoom( + PlatformUser localUser, + MemberConnectionPhase hostPhase = MemberConnectionPhase.Ready, + MemberConnectionPhase guestPhase = MemberConnectionPhase.Ready, + bool includeGuest = true, + string productId = "test-product", + string protocolVersion = "1", + string gameProtocolVersion = "game-1", + string contentRevision = "content-1", + string buildVersion = "build-1", + string gameRoomId = "gallery-room", + long sessionGeneration = 1) + { + List members = new List + { + new RoomMember(Host, hostPhase, true, 0) + }; + if (includeGuest) + { + members.Add(new RoomMember(Guest, guestPhase, false, 1)); + } + + return new RoomSnapshot( + new RoomId("room-1"), + Host.Id, + members, + 2, + RoomVisibility.FriendsOnly, + true, + productId, + protocolVersion, + gameProtocolVersion, + contentRevision, + buildVersion, + gameRoomId, + new SessionId("session-1"), + sessionGeneration, + SessionPhase.InRoom, + false); + } + + public static MultiplayerPeer GuestPeer() + { + return new MultiplayerPeer(Guest, 1); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs.meta new file mode 100644 index 00000000..d73038fd --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3f0b0508f1397a145af08ba0c3f9e9c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs new file mode 100644 index 00000000..6764c09a --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs @@ -0,0 +1,429 @@ +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.Tests +{ + public sealed class MultiplayerSessionCoordinatorTests + { + private MultiplayerSessionConfig _config; + private FakePlatformRoomService _platform; + private FakeConnectionDriver _driver; + private FakeGameAdapter _game; + private MultiplayerSessionCoordinator _coordinator; + + [SetUp] + public void SetUp() + { + _config = ScriptableObject.CreateInstance(); + _platform = new FakePlatformRoomService + { + LocalUser = TestData.Host, + CreateResult = OperationResult.Success(TestData.CreateRoom(TestData.Host)), + JoinResult = OperationResult.Success(TestData.CreateRoom(TestData.Guest)) + }; + _driver = new FakeConnectionDriver(); + _game = new FakeGameAdapter(); + _coordinator = new MultiplayerSessionCoordinator(_config, _platform, _driver, _game); + } + + [TearDown] + public void TearDown() + { + if (_coordinator != null) + { + _coordinator.DisposeAsync().GetAwaiter().GetResult(); + } + + if (_config != null) + { + Object.DestroyImmediate(_config); + } + } + + [Test] + public void CreateRoom_CompletesInStableHostRoom() + { + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + + OperationResult result = Complete( + _coordinator.CreateRoomAsync(CancellationToken.None)); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(SessionPhase.InRoom, _coordinator.Phase); + Assert.AreSame(result.Value, _coordinator.CurrentRoom); + Assert.AreEqual(1, _driver.StartHostCalls); + Assert.AreEqual(1, _game.PrepareCalls); + Assert.AreEqual(RetryOperationKind.None, _coordinator.GetSnapshot().RetryOperation); + Assert.AreEqual(SessionPhase.InRoom, _platform.PublishedRooms[0].Phase); + } + + [Test] + public void ConcurrentHighLevelOperation_ReturnsBusyWithoutChangingActiveFlow() + { + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + TaskCompletionSource> completion = + new TaskCompletionSource>(); + _platform.CreateHandler = (options, token) => completion.Task; + + Task> createTask = + _coordinator.CreateRoomAsync(CancellationToken.None); + Assert.AreEqual(1, _platform.CreateCalls); + + OperationResult concurrentJoin = + Complete(_coordinator.JoinRoomAsync(new RoomId("other-room"), CancellationToken.None)); + + Assert.IsFalse(concurrentJoin.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.Busy, concurrentJoin.ErrorCode); + Assert.AreEqual(SessionPhase.CreatingRoom, _coordinator.Phase); + + completion.SetResult(OperationResult.Success(TestData.CreateRoom(TestData.Host))); + Assert.IsTrue(Complete(createTask).Succeeded); + Assert.AreEqual(SessionPhase.InRoom, _coordinator.Phase); + } + + [Test] + public void CreateFailure_EntersFailedAndRetainsRetryIntent() + { + _platform.CreateResult = OperationResult.Failure( + MultiplayerErrorCode.CreateFailed, + "test.create_failed"); + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + + OperationResult failed = Complete( + _coordinator.CreateRoomAsync(CancellationToken.None)); + + Assert.IsFalse(failed.Succeeded); + Assert.AreEqual(SessionPhase.Failed, _coordinator.Phase); + Assert.AreEqual(RetryOperationKind.CreateRoom, _coordinator.GetSnapshot().RetryOperation); + + _platform.CreateResult = OperationResult.Success(TestData.CreateRoom(TestData.Host)); + OperationResult retry = Complete( + _coordinator.RetryAsync(CancellationToken.None)); + + Assert.IsTrue(retry.Succeeded); + Assert.AreEqual(SessionPhase.InRoom, _coordinator.Phase); + } + + [Test] + public void SuccessfulPlatformResultWithoutRoom_EntersFailedInsteadOfThrowing() + { + _platform.CreateResult = OperationResult.Success(null); + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + + OperationResult result = + Complete(_coordinator.CreateRoomAsync(CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.CreateFailed, result.ErrorCode); + Assert.AreEqual(SessionPhase.Failed, _coordinator.Phase); + Assert.IsNull(_coordinator.CurrentRoom); + } + + [Test] + public void CreateRoom_WithIncompleteGameCompatibilityFailsBeforePlatformCall() + { + _game.Compatibility = new CompatibilityDescriptor( + "test-product", + string.Empty, + "content-1", + "build-1", + "gallery-room"); + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + + OperationResult result = Complete( + _coordinator.CreateRoomAsync(CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.InvalidConfiguration, result.ErrorCode); + Assert.AreEqual(SessionPhase.Failed, _coordinator.Phase); + Assert.AreEqual(0, _platform.CreateCalls); + } + + [Test] + public void JoinCompatibilityFailure_LeavesPlatformAndEntersFailed() + { + _platform.LocalUser = TestData.Guest; + _platform.JoinResult = OperationResult.Success( + TestData.CreateRoom(TestData.Guest, productId: "different-product")); + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + + OperationResult result = Complete(_coordinator.JoinRoomAsync( + new RoomId("room-1"), + CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.ProductMismatch, result.ErrorCode); + Assert.AreEqual(SessionPhase.Failed, _coordinator.Phase); + Assert.AreEqual(1, _platform.LeaveCalls); + } + + [Test] + public void JoinNonJoinableRoom_FailsBeforeStartingTransport() + { + _platform.LocalUser = TestData.Guest; + _platform.JoinResult = OperationResult.Success( + TestData.CreateRoom(TestData.Guest).WithSession( + 2, + SessionPhase.InGame, + true, + false)); + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + + OperationResult result = Complete(_coordinator.JoinRoomAsync( + new RoomId("room-1"), + CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.RoomStarted, result.ErrorCode); + Assert.AreEqual(0, _driver.StartClientCalls); + Assert.AreEqual(1, _platform.LeaveCalls); + } + + [Test] + public void JoinSuccess_WaitsForAuthoritativeSynchronizationConfirmation() + { + _platform.LocalUser = TestData.Guest; + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + + OperationResult join = Complete(_coordinator.JoinRoomAsync( + new RoomId("room-1"), + CancellationToken.None)); + + Assert.IsTrue(join.Succeeded); + Assert.AreEqual(SessionPhase.Synchronizing, _coordinator.Phase); + + OperationResult staleConfirmation = _coordinator.ConfirmLocalSynchronization( + join.Value.SessionId, + join.Value.SessionGeneration + 1); + Assert.AreEqual(MultiplayerErrorCode.SessionMismatch, staleConfirmation.ErrorCode); + Assert.AreEqual(SessionPhase.Synchronizing, _coordinator.Phase); + + OperationResult confirmation = _coordinator.ConfirmLocalSynchronization( + join.Value.SessionId, + join.Value.SessionGeneration); + Assert.IsTrue(confirmation.Succeeded); + Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); + } + + [Test] + public void ConfirmedInviteFromExistingRoom_LeavesThenJoinsRequestedRoom() + { + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + Assert.IsTrue(Complete(_coordinator.CreateRoomAsync(CancellationToken.None)).Succeeded); + _platform.LocalUser = TestData.Guest; + _platform.EmitJoinRequest(new JoinRequest(new RoomId("invited-room"), TestData.Host)); + + OperationResult result = + Complete(_coordinator.AcceptJoinRequestAsync(CancellationToken.None)); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(1, _platform.LeaveCalls); + Assert.AreEqual(1, _platform.JoinCalls); + Assert.AreEqual(SessionPhase.Synchronizing, _coordinator.Phase); + } + + [Test] + public void RecoverableStartFailure_ReturnsReadyWithNewGeneration() + { + ReachReadyHost(); + long before = _coordinator.CurrentRoom.SessionGeneration; + int publishCount = _platform.PublishedRooms.Count; + _game.DefaultPrepareResult = OperationResult.Failure( + MultiplayerErrorCode.SynchronizationFailed, + "test.start_failed"); + + OperationResult result = Complete(_coordinator.StartGameAsync(CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); + Assert.AreEqual(before + 2, _coordinator.CurrentRoom.SessionGeneration); + Assert.IsTrue(_coordinator.CurrentRoom.IsJoinable); + Assert.AreEqual(SessionPhase.Starting, _platform.PublishedRooms[publishCount].Phase); + Assert.AreEqual(before + 1, _platform.PublishedRooms[publishCount].SessionGeneration); + Assert.AreEqual(SessionPhase.Ready, _platform.PublishedRooms[publishCount + 1].Phase); + Assert.AreEqual(before + 2, _platform.PublishedRooms[publishCount + 1].SessionGeneration); + } + + [Test] + public void SuccessfulStart_PublishesStartingThenInGame() + { + ReachReadyHost(); + long before = _coordinator.CurrentRoom.SessionGeneration; + int publishCount = _platform.PublishedRooms.Count; + + OperationResult result = Complete(_coordinator.StartGameAsync(CancellationToken.None)); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(SessionPhase.Starting, _platform.PublishedRooms[publishCount].Phase); + Assert.IsFalse(_platform.PublishedRooms[publishCount].IsJoinable); + Assert.AreEqual(SessionPhase.InGame, _platform.PublishedRooms[publishCount + 1].Phase); + Assert.AreEqual(before + 1, _platform.PublishedRooms[publishCount + 1].SessionGeneration); + } + + [Test] + public void Start_WhenRoomStateCannotBePublished_EntersFailed() + { + ReachReadyHost(); + _platform.PublishResult = OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + "test.publish_failed"); + + OperationResult result = Complete(_coordinator.StartGameAsync(CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.PlatformUnavailable, result.ErrorCode); + Assert.AreEqual(SessionPhase.Failed, _coordinator.Phase); + Assert.AreEqual(SessionPhase.Failed, _coordinator.CurrentRoom.Phase); + Assert.IsFalse(_coordinator.CurrentRoom.IsJoinable); + } + + [Test] + public void IntentionalLeave_DoesNotEnterReconnectAndEndsIdleEvenWhenCleanupFails() + { + ReachInGameHost(); + _driver.EmitDisconnectOnStop = true; + _driver.StopResult = OperationResult.Failure( + MultiplayerErrorCode.LeaveFailed, + "test.stop_failed"); + + OperationResult result = Complete(_coordinator.LeaveAsync(CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(SessionPhase.Idle, _coordinator.Phase); + Assert.IsNull(_coordinator.CurrentRoom); + Assert.AreEqual(1, _game.SessionEndedCalls); + } + + [Test] + public void UnexpectedDisconnect_EntersReconnectAndCanBeCancelledToRecovery() + { + ReachInGameHost(); + + _driver.Emit(new ConnectionEvent( + ConnectionEventType.LocalDisconnected, + TestData.Host.Id, + MultiplayerErrorCode.TransportFailed, + "test.connection_lost")); + _coordinator.UpdateReconnectElapsed(System.TimeSpan.FromSeconds(3)); + + Assert.AreEqual(SessionPhase.Reconnecting, _coordinator.Phase); + Assert.AreEqual(System.TimeSpan.FromSeconds(15), _coordinator.GetSnapshot().ReconnectRemaining); + + OperationResult cancel = _coordinator.CancelReconnect(); + Assert.AreEqual(MultiplayerErrorCode.Cancelled, cancel.ErrorCode); + Assert.AreEqual(SessionPhase.Recovery, _coordinator.Phase); + } + + [Test] + public void HostLeftDuringReconnect_EndsInRecoveryImmediately() + { + ReachInGameHost(); + _driver.Emit(new ConnectionEvent( + ConnectionEventType.LocalDisconnected, + TestData.Host.Id, + MultiplayerErrorCode.TransportFailed, + "test.connection_lost")); + + _driver.Emit(new ConnectionEvent( + ConnectionEventType.LocalDisconnected, + TestData.Host.Id, + MultiplayerErrorCode.HostLeft, + "test.host_left")); + + Assert.AreEqual(SessionPhase.Recovery, _coordinator.Phase); + Assert.AreEqual(MultiplayerErrorCode.HostLeft, _coordinator.GetSnapshot().LastResult.ErrorCode); + } + + [Test] + public void ServerPeerDisconnectAndExpiry_UseGameAdapterHooksWithoutChangingHostPhase() + { + ReachInGameHost(); + + _driver.Emit(new ConnectionEvent( + ConnectionEventType.PeerDisconnected, + TestData.Guest.Id, + MultiplayerErrorCode.TransportFailed, + "test.peer_disconnected")); + + Assert.AreEqual(1, _game.PeerDisconnectedCalls); + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + Assert.AreEqual( + MemberConnectionPhase.Disconnected, + _coordinator.CurrentRoom.Members[1].ConnectionPhase); + + OperationResult expiry = _coordinator.ExpirePeerReconnect(TestData.GuestPeer()); + Assert.IsTrue(expiry.Succeeded); + Assert.AreEqual(1, _game.ReconnectExpiredCalls); + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + } + + [Test] + public void StaleRoomEvent_DoesNotReplaceCurrentSnapshot() + { + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + Assert.IsTrue(Complete(_coordinator.CreateRoomAsync(CancellationToken.None)).Succeeded); + RoomSnapshot current = _coordinator.CurrentRoom; + RoomSnapshot stale = TestData.CreateRoom(TestData.Host, sessionGeneration: current.SessionGeneration + 1); + + _platform.EmitRoomEvent(new PlatformRoomEvent( + RoomEventType.RoomUpdated, + current.Id, + stale.SessionGeneration, + stale, + default(PlatformUserId))); + + Assert.AreSame(current, _coordinator.CurrentRoom); + } + + [Test] + public void PlatformMembershipRefresh_DoesNotRegressKnownReadyPhases() + { + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + Assert.IsTrue(Complete(_coordinator.CreateRoomAsync(CancellationToken.None)).Succeeded); + RoomSnapshot refresh = TestData.CreateRoom( + TestData.Host, + MemberConnectionPhase.LobbyOnly, + MemberConnectionPhase.Connected); + + _platform.EmitRoomEvent(new PlatformRoomEvent( + RoomEventType.MemberJoined, + refresh.Id, + refresh.SessionGeneration, + refresh, + TestData.Guest.Id)); + OperationResult synchronized = Complete(_coordinator.SynchronizePeerAsync( + TestData.GuestPeer(), + CancellationToken.None)); + + Assert.IsTrue(synchronized.Succeeded); + Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); + Assert.AreEqual( + MemberConnectionPhase.Ready, + _coordinator.CurrentRoom.Members[0].ConnectionPhase); + } + + private void ReachReadyHost() + { + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + Assert.IsTrue(Complete(_coordinator.CreateRoomAsync(CancellationToken.None)).Succeeded); + Assert.IsTrue(Complete(_coordinator.SynchronizePeerAsync( + TestData.GuestPeer(), + CancellationToken.None)).Succeeded); + Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); + } + + private void ReachInGameHost() + { + ReachReadyHost(); + Assert.IsTrue(Complete(_coordinator.StartGameAsync(CancellationToken.None)).Succeeded); + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + } + + private static T Complete(Task task) + { + return task.GetAwaiter().GetResult(); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs.meta new file mode 100644 index 00000000..e53121c2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d5bbd778cf964a446a6b22c02bfe4f05 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs new file mode 100644 index 00000000..491e189c --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace ZeroEngine.Multiplayer.Tests +{ + public sealed class MultiplayerSessionRulesTests + { + [Test] + public void StateMachine_OnlyAllowsDocumentedTransitions() + { + HashSet legal = new HashSet + { + Key(SessionPhase.Offline, SessionPhase.Initializing), + Key(SessionPhase.Initializing, SessionPhase.Idle), + Key(SessionPhase.Initializing, SessionPhase.Failed), + Key(SessionPhase.Idle, SessionPhase.CreatingRoom), + Key(SessionPhase.Idle, SessionPhase.JoiningRoom), + Key(SessionPhase.CreatingRoom, SessionPhase.Connecting), + Key(SessionPhase.CreatingRoom, SessionPhase.Failed), + Key(SessionPhase.JoiningRoom, SessionPhase.Connecting), + Key(SessionPhase.JoiningRoom, SessionPhase.Failed), + Key(SessionPhase.Connecting, SessionPhase.InRoom), + Key(SessionPhase.Connecting, SessionPhase.Synchronizing), + Key(SessionPhase.Connecting, SessionPhase.Failed), + Key(SessionPhase.InRoom, SessionPhase.Synchronizing), + Key(SessionPhase.InRoom, SessionPhase.Leaving), + Key(SessionPhase.Synchronizing, SessionPhase.Ready), + Key(SessionPhase.Synchronizing, SessionPhase.InRoom), + Key(SessionPhase.Synchronizing, SessionPhase.InGame), + Key(SessionPhase.Synchronizing, SessionPhase.Failed), + Key(SessionPhase.Ready, SessionPhase.Starting), + Key(SessionPhase.Ready, SessionPhase.InRoom), + Key(SessionPhase.Ready, SessionPhase.Leaving), + Key(SessionPhase.Starting, SessionPhase.InGame), + Key(SessionPhase.Starting, SessionPhase.Ready), + Key(SessionPhase.Starting, SessionPhase.Failed), + Key(SessionPhase.InGame, SessionPhase.Reconnecting), + Key(SessionPhase.InGame, SessionPhase.Leaving), + Key(SessionPhase.Reconnecting, SessionPhase.Synchronizing), + Key(SessionPhase.Reconnecting, SessionPhase.Recovery), + Key(SessionPhase.Recovery, SessionPhase.Leaving), + Key(SessionPhase.Failed, SessionPhase.CreatingRoom), + Key(SessionPhase.Failed, SessionPhase.JoiningRoom), + Key(SessionPhase.Failed, SessionPhase.Idle), + Key(SessionPhase.Failed, SessionPhase.Leaving), + Key(SessionPhase.Leaving, SessionPhase.Idle) + }; + + Array phases = Enum.GetValues(typeof(SessionPhase)); + foreach (SessionPhase source in phases) + { + foreach (SessionPhase target in phases) + { + Assert.AreEqual( + legal.Contains(Key(source, target)), + MultiplayerSessionStateMachine.CanTransition(source, target), + source + " -> " + target); + } + } + } + + [Test] + public void StateMachine_InvalidTransitionPreservesPhase() + { + MultiplayerSessionStateMachine stateMachine = new MultiplayerSessionStateMachine(SessionPhase.Idle); + + OperationResult result = stateMachine.TryTransition(SessionPhase.InGame); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.InvalidState, result.ErrorCode); + Assert.AreEqual(SessionPhase.Idle, stateMachine.Phase); + } + + [Test] + public void OperationGenerationGate_RejectsStaleCompletion() + { + OperationGenerationGate gate = new OperationGenerationGate(); + int first = gate.Begin(); + int second = gate.Begin(); + + Assert.IsFalse(gate.IsCurrent(first)); + Assert.IsTrue(gate.IsCurrent(second)); + + gate.Invalidate(); + Assert.IsFalse(gate.IsCurrent(second)); + } + + [TestCase(SessionPhase.Idle, InviteRouteAction.Present)] + [TestCase(SessionPhase.InRoom, InviteRouteAction.ConfirmLeaveCurrentSession)] + [TestCase(SessionPhase.Starting, InviteRouteAction.QueueUntilStable)] + [TestCase(SessionPhase.InGame, InviteRouteAction.QueueUntilStable)] + [TestCase(SessionPhase.Leaving, InviteRouteAction.QueueUntilStable)] + public void InviteRouter_RoutesBySessionPhase(SessionPhase phase, InviteRouteAction expected) + { + InviteRouter router = new InviteRouter(); + JoinRequest request = new JoinRequest(new RoomId("invited-room"), TestData.Guest); + + InviteRouteAction action = router.Route(request, phase, null); + + Assert.AreEqual(expected, action); + Assert.IsTrue(router.HasPending); + } + + [Test] + public void InviteRouter_DeduplicatesPendingRoom() + { + InviteRouter router = new InviteRouter(); + JoinRequest request = new JoinRequest(new RoomId("invited-room"), TestData.Guest); + router.Route(request, SessionPhase.Idle, null); + + InviteRouteAction action = router.Route(request, SessionPhase.InGame, null); + + Assert.AreEqual(InviteRouteAction.IgnoreDuplicate, action); + } + + [Test] + public void ReconnectPolicy_UsesConfiguredBackoffAndTimeout() + { + ReconnectPolicy policy = CreateReconnectPolicy(); + + ReconnectAttempt first; + ReconnectAttempt third; + Assert.AreEqual( + ReconnectBlockReason.None, + policy.Evaluate(0, TimeSpan.Zero, false, out first)); + Assert.AreEqual( + ReconnectBlockReason.None, + policy.Evaluate(2, TimeSpan.FromSeconds(10), false, out third)); + + Assert.AreEqual(1, first.Number); + Assert.AreEqual(TimeSpan.FromSeconds(0.5), first.Delay); + Assert.AreEqual(TimeSpan.FromSeconds(4), first.Timeout); + Assert.AreEqual(3, third.Number); + Assert.AreEqual(TimeSpan.FromSeconds(3), third.Delay); + Assert.AreEqual(TimeSpan.FromSeconds(4), third.Timeout); + } + + [Test] + public void ReconnectPolicy_StopsForCancelAttemptsDeadlineAndGrace() + { + ReconnectPolicy policy = CreateReconnectPolicy(); + ReconnectAttempt ignored; + + Assert.AreEqual( + ReconnectBlockReason.Cancelled, + policy.Evaluate(0, TimeSpan.Zero, true, out ignored)); + Assert.AreEqual( + ReconnectBlockReason.AttemptsExhausted, + policy.Evaluate(3, TimeSpan.Zero, false, out ignored)); + Assert.AreEqual( + ReconnectBlockReason.HardDeadlineExceeded, + policy.Evaluate(0, TimeSpan.FromSeconds(18), false, out ignored)); + Assert.AreEqual( + ReconnectBlockReason.GraceExpired, + policy.Evaluate(0, TimeSpan.FromSeconds(20), false, out ignored)); + Assert.AreEqual(TimeSpan.Zero, policy.GetRemainingGrace(TimeSpan.FromSeconds(21))); + } + + [Test] + public void ReconnectSeatRegistry_OnlyOriginalIdentityCanReclaimSeat() + { + SessionId sessionId = new SessionId("session"); + ReconnectSeatRegistry registry = new ReconnectSeatRegistry(sessionId, 7); + PlatformUserId owner = new PlatformUserId("owner"); + Assert.IsTrue(registry.Register(1, owner).Succeeded); + Assert.IsTrue(registry.MarkDisconnected(owner).Succeeded); + + OperationResult impostor = registry.TryReclaim( + 1, + new PlatformUserId("impostor"), + sessionId, + 7); + OperationResult staleSession = registry.TryReclaim(1, owner, sessionId, 6); + OperationResult ownerResult = registry.TryReclaim(1, owner, sessionId, 7); + OperationResult duplicate = registry.TryReclaim(1, owner, sessionId, 7); + + Assert.AreEqual(MultiplayerErrorCode.UnauthorizedPeer, impostor.ErrorCode); + Assert.AreEqual(MultiplayerErrorCode.SessionMismatch, staleSession.ErrorCode); + Assert.IsTrue(ownerResult.Succeeded); + Assert.AreEqual(1, ownerResult.Value); + Assert.AreEqual(MultiplayerErrorCode.SeatClaimed, duplicate.ErrorCode); + } + + [Test] + public void DefaultConfig_IsInternallyConsistent() + { + MultiplayerSessionConfig config = ScriptableObject.CreateInstance(); + try + { + Assert.IsEmpty(config.ValidateConfiguration()); + Assert.DoesNotThrow(() => config.CreateReconnectPolicy()); + } + finally + { + UnityEngine.Object.DestroyImmediate(config); + } + } + + private static ReconnectPolicy CreateReconnectPolicy() + { + return new ReconnectPolicy( + 3, + TimeSpan.FromSeconds(4), + new[] + { + TimeSpan.FromSeconds(0.5), + TimeSpan.FromSeconds(1.5), + TimeSpan.FromSeconds(3) + }, + TimeSpan.FromSeconds(18), + TimeSpan.FromSeconds(20)); + } + + private static string Key(SessionPhase source, SessionPhase target) + { + return source + ">" + target; + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs.meta new file mode 100644 index 00000000..ed492085 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 387c5d497269e7d4b89fec54441b9f3c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs new file mode 100644 index 00000000..c7bc35c1 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs @@ -0,0 +1,19 @@ +using NUnit.Framework; +using ZeroEngine.Multiplayer.Editor; + +namespace ZeroEngine.Multiplayer.Tests +{ + public sealed class MultiplayerSetupValidatorTests + { + [Test] + public void ValidateConfig_NullConfigReturnsActionableError() + { + var issues = MultiplayerSetupValidator.ValidateConfig(null); + + Assert.That(issues.Count, Is.EqualTo(1)); + Assert.That(issues[0].Code, Is.EqualTo("multiplayer.setup.config_missing")); + Assert.That(issues[0].Severity, Is.EqualTo(MultiplayerSetupIssueSeverity.Error)); + } + + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs.meta new file mode 100644 index 00000000..86fa742e --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSetupValidatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00621651e68443e3bddeb7c5d7fe759e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs new file mode 100644 index 00000000..5bf333ed --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs @@ -0,0 +1,113 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using ZeroEngine.Multiplayer.Presentation; + +namespace ZeroEngine.Multiplayer.Tests +{ + public sealed class MultiplayerViewStateTests + { + private MultiplayerSessionConfig _config; + + [SetUp] + public void SetUp() + { + _config = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown() + { + UnityEngine.Object.DestroyImmediate(_config); + } + + [Test] + public void Idle_AllowsCreateOnly() + { + MultiplayerViewState state = MultiplayerViewState.Create( + Snapshot(SessionPhase.Idle, null, false, RetryOperationKind.None, OperationResult.Success()), + _config); + + Assert.IsTrue(state.CanCreate); + Assert.IsFalse(state.CanInvite); + Assert.IsFalse(state.CanStart); + Assert.IsFalse(state.CanLeave); + Assert.IsFalse(state.CanRetry); + } + + [Test] + public void ReadyFullHostWithAllMembersReady_CanStartAndLeaveButCannotInvite() + { + RoomSnapshot room = TestData.CreateRoom(TestData.Host); + MultiplayerViewState state = MultiplayerViewState.Create( + Snapshot(SessionPhase.Ready, room, true, RetryOperationKind.None, OperationResult.Success()), + _config); + + Assert.IsFalse(state.CanInvite); + Assert.IsTrue(state.CanStart); + Assert.IsTrue(state.CanLeave); + } + + [Test] + public void InRoomHostWithOpenSlot_CanInvite() + { + RoomSnapshot room = TestData.CreateRoom(TestData.Host, includeGuest: false); + MultiplayerViewState state = MultiplayerViewState.Create( + Snapshot(SessionPhase.InRoom, room, true, RetryOperationKind.None, OperationResult.Success()), + _config); + + Assert.IsTrue(state.CanInvite); + Assert.IsFalse(state.CanStart); + } + + [Test] + public void ReadyHostWithUnsynchronizedMember_CannotStart() + { + RoomSnapshot room = TestData.CreateRoom( + TestData.Host, + guestPhase: MemberConnectionPhase.Connected); + + MultiplayerViewState state = MultiplayerViewState.Create( + Snapshot(SessionPhase.Ready, room, true, RetryOperationKind.None, OperationResult.Success()), + _config); + + Assert.IsFalse(state.CanStart); + } + + [Test] + public void FailedCreate_ExposesRetryAndLocalizedErrorData() + { + OperationResult failure = OperationResult.Failure( + MultiplayerErrorCode.CreateFailed, + "test.create_failed", + "detail"); + MultiplayerViewState state = MultiplayerViewState.Create( + Snapshot(SessionPhase.Failed, null, false, RetryOperationKind.CreateRoom, failure), + _config); + + Assert.IsTrue(state.CanRetry); + Assert.IsTrue(state.CanLeave); + Assert.AreEqual(MultiplayerErrorCode.CreateFailed, state.ErrorCode); + Assert.AreEqual("test.create_failed", state.ErrorMessageKey); + Assert.AreEqual("detail", state.ErrorArguments[0]); + } + + private static MultiplayerSessionSnapshot Snapshot( + SessionPhase phase, + RoomSnapshot room, + bool isServer, + RetryOperationKind retryOperation, + OperationResult result) + { + return new MultiplayerSessionSnapshot( + phase, + room, + TestData.Host, + isServer, + false, + retryOperation, + result, + TimeSpan.Zero); + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs.meta new file mode 100644 index 00000000..5e690313 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerViewStateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 735d970741f58a84797504220777138c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam.meta new file mode 100644 index 00000000..a5d98fb0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6017d55dc62642d7ae78db1c3b93b0ab +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs new file mode 100644 index 00000000..9009689c --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using NUnit.Framework; +using ZeroEngine.Multiplayer.Steam; + +namespace ZeroEngine.Multiplayer.Tests.Steam +{ + public sealed class SteamRoomMetadataTests + { + [Test] + public void RoomService_WhenSteamIsOffline_ConstructsWithoutInitializing() + { + FakeSteamRuntime runtime = new FakeSteamRuntime(); + SteamRoomService service = null; + + Assert.DoesNotThrow(() => service = new SteamRoomService(runtime, "ze_")); + Assert.That(runtime.EnsureInitializedCalls, Is.EqualTo(0)); + Assert.That(service.IsAvailable, Is.False); + Assert.That(service.UnavailableReasonKey, Is.EqualTo("multiplayer.steam.offline")); + + service.DisposeAsync().GetAwaiter().GetResult(); + } + + [Test] + public void CreateAndRead_RoundTripsCompatibilityAndAuthority() + { + CompatibilityDescriptor compatibility = new CompatibilityDescriptor( + "test-product", + "game-1", + "content-1", + "build-1", + "room-config"); + RoomCreateOptions options = new RoomCreateOptions( + RoomVisibility.FriendsOnly, + 2, + false, + "1", + compatibility); + IReadOnlyDictionary values = SteamRoomMetadata.Create( + "ze_", + options, + new SessionId("session-1"), + 3, + new PlatformUserId("76561198000000000")); + + OperationResult result = SteamRoomMetadata.Read( + "ze_", + key => values.ContainsKey(key) ? values[key] : string.Empty); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Value.ProductId, Is.EqualTo("test-product")); + Assert.That(result.Value.SessionGeneration, Is.EqualTo(3)); + Assert.That(result.Value.HostId.Value, Is.EqualTo("76561198000000000")); + Assert.That(result.Value.Visibility, Is.EqualTo(RoomVisibility.FriendsOnly)); + Assert.That(result.Value.IsJoinable, Is.True); + } + + [Test] + public void Read_UnknownStateFailsStably() + { + Dictionary values = new Dictionary + { + ["ze_product"] = "product", + ["ze_protocol"] = "1", + ["ze_game_protocol"] = "1", + ["ze_content"] = "content", + ["ze_build"] = "build", + ["ze_room"] = "room", + ["ze_session"] = "session", + ["ze_generation"] = "1", + ["ze_state"] = "future-state", + ["ze_host"] = "76561198000000000", + ["ze_visibility"] = "friends", + ["ze_joinable"] = "1" + }; + + OperationResult result = SteamRoomMetadata.Read( + "ze_", + key => values.ContainsKey(key) ? values[key] : string.Empty); + + Assert.That(result.Succeeded, Is.False); + Assert.That(result.MessageKey, Is.EqualTo("multiplayer.steam.metadata_state_invalid")); + } + + [Test] + public void Read_InGameJoinabilityUsesExplicitMetadata() + { + RoomCreateOptions options = new RoomCreateOptions( + RoomVisibility.FriendsOnly, + 2, + true, + "1", + new CompatibilityDescriptor("product", "game", "content", "build", "room")); + IReadOnlyDictionary created = SteamRoomMetadata.Create( + "ze_", + options, + new SessionId("session"), + 2, + new PlatformUserId("76561198000000000")); + Dictionary values = new Dictionary(); + foreach (KeyValuePair pair in created) + { + values.Add(pair.Key, pair.Value); + } + values["ze_state"] = "ingame"; + values["ze_joinable"] = "1"; + + OperationResult result = SteamRoomMetadata.Read( + "ze_", + key => values.ContainsKey(key) ? values[key] : string.Empty); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Value.HasStarted, Is.True); + Assert.That(result.Value.IsJoinable, Is.True); + } + + private sealed class FakeSteamRuntime : ISteamRuntime + { + public bool IsAvailable => false; + public string UnavailableReasonKey => "multiplayer.steam.offline"; + public PlatformUser LocalUser => default(PlatformUser); + public int EnsureInitializedCalls { get; private set; } + + public OperationResult EnsureInitialized() + { + EnsureInitializedCalls++; + return OperationResult.Failure( + MultiplayerErrorCode.PlatformUnavailable, + UnavailableReasonKey); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs.meta new file mode 100644 index 00000000..90d956e2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/SteamRoomMetadataTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 24e13c6acf9146e781fdee455d10347b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef new file mode 100644 index 00000000..f0a5da62 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef @@ -0,0 +1,32 @@ +{ + "name": "ZeroEngine.Multiplayer.Tests.Steam.Editor", + "rootNamespace": "ZeroEngine.Multiplayer.Tests.Steam", + "references": [ + "ZeroEngine.Multiplayer.Core", + "ZeroEngine.Multiplayer.Steam", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS", + "ZEROENGINE_MULTIPLAYER_STEAM_TESTS" + ], + "versionDefines": [ + { + "name": "com.rlabrecque.steamworks.net", + "expression": "[2024.8.0,2025.0.0)", + "define": "ZEROENGINE_MULTIPLAYER_STEAM_TESTS" + } + ], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef.meta new file mode 100644 index 00000000..711dcaf0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/Steam/ZeroEngine.Multiplayer.Tests.Steam.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5fe17bd776a544ada78bf03536db67f0 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef new file mode 100644 index 00000000..aa946ef0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef @@ -0,0 +1,27 @@ +{ + "name": "ZeroEngine.Multiplayer.Tests.Editor", + "rootNamespace": "ZeroEngine.Multiplayer.Tests", + "references": [ + "ZeroEngine.Multiplayer.Core", + "ZeroEngine.Multiplayer.Local", + "ZeroEngine.Multiplayer.Editor", + "ZeroEngine.Multiplayer.Presentation", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef.meta b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef.meta new file mode 100644 index 00000000..c57e4c8b --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/ZeroEngine.Multiplayer.Tests.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fab24920d55ae8b41a639e6d9fcf99e4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.multiplayer/package.json b/com.zerogamestudio.zeroengine.multiplayer/package.json new file mode 100644 index 00000000..06105ab9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/package.json @@ -0,0 +1,32 @@ +{ + "name": "com.zerogamestudio.zeroengine.multiplayer", + "version": "0.2.0", + "displayName": "ZeroEngine.Multiplayer", + "description": "Configurable multiplayer room, FishNet connection, Steam lobby, LocalDirect, retry, and reconnect orchestration for ZeroEngine projects.", + "unity": "2022.3", + "unityRelease": "0f1", + "dependencies": {}, + "keywords": [ + "zeroengine", + "multiplayer", + "lobby", + "session", + "reconnect" + ], + "author": { + "name": "ZeroGameStudio" + }, + "repository": { + "type": "git", + "url": "https://github.com/liuzqk/zeroengine.git", + "directory": "com.zerogamestudio.zeroengine.multiplayer" + }, + "samples": [ + { + "displayName": "LocalDirect Room UI", + "description": "Minimal two-process FishNet room and readiness sample.", + "path": "Samples~/RoomUI" + } + ], + "license": "MIT" +} diff --git a/com.zerogamestudio.zeroengine.multiplayer/package.json.meta b/com.zerogamestudio.zeroengine.multiplayer/package.json.meta new file mode 100644 index 00000000..be4d52e2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.multiplayer/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f0204520b31c9974e94707f9dfc988ca +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 67f9120cea60943e7b4514a58b4bf799bfc95b11 Mon Sep 17 00:00:00 2001 From: ZeroGameStudio <837757433@qq.com> Date: Sun, 19 Jul 2026 18:46:12 +0800 Subject: [PATCH 2/2] feat(multiplayer): add resilient session restore --- CHANGELOG.md | 1 + .../README.md | 24 +- .../Runtime/Core/MultiplayerContracts.cs | 5 + .../Core/MultiplayerSessionCoordinator.cs | 599 ++++++++++++++++-- .../Runtime/Core/MultiplayerSessionRules.cs | 21 +- .../FishNet/FishNetConnectionDriver.cs | 164 ++++- .../Presentation/MultiplayerViewState.cs | 8 +- .../Runtime/Steam/SteamRuntimeOwner.cs | 21 + .../RoomUI/LocalRoomSampleController.cs | 12 + .../Tests/Editor/MultiplayerFakes.cs | 31 + .../MultiplayerSessionCoordinatorTests.cs | 242 ++++++- .../Editor/MultiplayerSessionRulesTests.cs | 9 + 12 files changed, 1039 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6adc63f..9ea4a956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ tracked in each package's `package.json`. ## Unreleased +- Expanded `com.zerogamestudio.zeroengine.multiplayer` with Multipass route selection, pre-transport game preparation, post-connect local synchronization, automatic client reconnect/restore, authenticated remote-start confirmation, and reconnect-focused coordinator tests. - Added root project documentation. - Added contribution, support, and security guidance. - Added MIT licensing. diff --git a/com.zerogamestudio.zeroengine.multiplayer/README.md b/com.zerogamestudio.zeroengine.multiplayer/README.md index 4588bfdd..b04b6327 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/README.md +++ b/com.zerogamestudio.zeroengine.multiplayer/README.md @@ -6,12 +6,15 @@ The `Core` assembly owns public models, configuration, state transitions, compat ## Status -Version `0.2.0` contains the M2 implementation: +Version `0.2.0` contains the shared room/session implementation used by the +GalleryKeeper M4 integration: - `LocalDevelopmentRoomService` and a stable command-line descriptor for two-process development rooms. -- `FishNetConnectionDriver` for Tugboat LocalDirect or FishySteamworks SteamP2P, plus an identity bridge based on FishNet's authenticated connection event. +- `FishNetConnectionDriver` for Tugboat LocalDirect or FishySteamworks SteamP2P, including selected-route control behind one FishNet `Multipass`, plus an identity bridge based on FishNet's authenticated connection event. - the single-owner `SteamRuntimeOwner`, Steam lobby metadata/service, invitations, refresh, leave, and host-loss detection. - host-owned room-state publishing for phase, joinability, and monotonic session generation, plus a fail-closed remote admission gate. +- pre-transport game preparation, post-connect local synchronization, server-side peer restore, and automatic client reconnect with configured retry/deadline limits. +- an authenticated game-channel start confirmation for development room services whose platform descriptor cannot receive cross-process metadata updates. - Setup Validator, config inspector, local player launcher, and the importable **LocalDirect Room UI** sample. LocalDirect identities are explicitly untrusted development identities. SteamP2P resolves the remote platform identity from FishySteamworks' authenticated server connection address. @@ -26,26 +29,27 @@ The tested baseline is fixed to: | Dependency | Version | Pinned source commit | | --- | --- | --- | -| FishNet | 4.6.15 | `caf94d2cf5bd7cd2531452b5bf68d20c9a8eae1a` (`4.6.15.1` tag) | +| FishNet | 4.7.2 | `de19b5d66459f60400ffd0edc443c4da173a01e7` | | FishySteamworks | 4.1.1 | `21e858249249e2c322365fe9fefbe865f290b0d9` | | Steamworks.NET | 2024.8.0 | `a2fc889ab2672981ec3e6225d551d86ce6923121` | -Pin normalized package mirrors built from these commits; do not track a moving branch. The upstream FishNet tag declares the non-UPM version `4.6.15.1` and includes duplicate quaternion-compression sources on Windows, while FishySteamworks is a repository-root package whose tagged manifest still declares `4.1.0`. Consequently, the two upstream repositories cannot be installed with the previously suggested direct `?path=` URLs. A tested internal mirror or vendored package must normalize FishNet to `4.6.15`, remove the duplicate compression sources, and normalize FishySteamworks to `4.1.1` without changing runtime code. +Pin the FishNet and Steamworks.NET source commits; do not track a moving branch. FishNet `4.7.2` is a valid package at `Assets/FishNet` and avoids the invalid four-part manifest version and duplicate compression sources found in the earlier `4.6.15.1` candidate. FishySteamworks `4.1.1` must be imported from the upstream release asset (SHA-256 `5698D16BD29B8B08D35E12A9B817CE69992F70D7C14B64810961691ECD9AFC57`): its Git root package omits the transport source, while its nested package has no asmdef and is therefore not compiled from `Packages/`. ```json -"com.firstgeargames.fishnet": "#", -"com.firstgeargames.fishysteamworks": "#", +"com.firstgeargames.fishnet": "https://github.com/FirstGearGames/FishNet.git?path=/Assets/FishNet#de19b5d66459f60400ffd0edc443c4da173a01e7", "com.rlabrecque.steamworks.net": "https://github.com/rlabrecque/Steamworks.NET.git?path=com.rlabrecque.steamworks.net#a2fc889ab2672981ec3e6225d551d86ce6923121" ``` -The source commits in the table remain the provenance pins; the mirror commits record only packaging normalization. Do not use LLS's Unity-6-modified FishNet copy as the Unity 2022 source, and do not retain Asset-folder copies with the same assembly names when installing these UPM packages. +This exact dependency set passed all 59 package EditMode tests in a fresh Unity 2022.3.62f3 project. Do not use LLS's Unity-6-modified FishNet copy as the Unity 2022 source, and do not retain Asset-folder copies with the same assembly names when installing these UPM packages. ## Core flow 1. Construct `MultiplayerSessionCoordinator` with one `MultiplayerSessionConfig`, an `IPlatformRoomService`, an `INetworkConnectionDriver`, and an `IMultiplayerGameAdapter`. 2. Initialize, create or join, and consume `MultiplayerSessionSnapshot` or `MultiplayerViewState` from project UI. -3. A joining client remains in `Synchronizing` after local preparation. The authoritative game/network bridge must call `ConfirmLocalSynchronization` with the current session ID and generation before the client becomes `Ready` or returns to `InGame`. -4. Server-side peer synchronization and restoration use `SynchronizePeerAsync` and `RestorePeerAsync`; the package never reads game-specific state. +3. `PrepareSessionAsync` runs before the selected transport starts, so the project can load and register its network scene without racing the first connection. `SynchronizeLocalAsync` runs after the connection is established and must wait for the authoritative local snapshot before the client becomes `Ready` or returns to `InGame`. +4. A client may learn that the host started through platform room metadata. A project with a static development descriptor instead calls `ConfirmRemoteSessionStarted` from its authenticated server-to-client game message; the call is generation-checked and idempotent. +5. Server-side peer synchronization and restoration use `SynchronizePeerAsync` and `RestorePeerAsync`. An unexpected in-game client disconnect automatically refreshes the room, reconnects the same host/session/generation, and waits for `SynchronizeLocalAsync` before restoring `InGame`. +6. The package never reads game-specific state. Host room services may implement `IRoomStatePublisher`; the coordinator then publishes `InRoom`, `Ready`, `Starting`, rollback, and `InGame` state without exposing platform APIs to UI or game code. A room service used by `MultiplayerBootstrap` as a FishNet host must implement `IRemoteConnectionAuthorizer`. The bootstrap wires this automatically: Steam re-reads current Lobby metadata and membership before admitting an authenticated FishySteamworks identity, while LocalDirect accepts only the explicitly configured development identity. @@ -63,6 +67,8 @@ Import **LocalDirect Room UI** from Package Manager, follow its README to create Real Steam remains a manual two-account test: compile/setup validation and LocalDirect dual-process testing do not prove overlay, friend invitation, relay connectivity, or account entitlement. +The GalleryKeeper Unity 6000.3.10f1 integration currently runs 67 package EditMode tests inside its 110-test integrated EditMode suite. Its Development Player dual-process smoke covers `Ready`, authoritative start, an unexpected client disconnect, reconnect, restored snapshot, full puzzle completion, and leave. This does not replace the fresh Unity 2022 dependency-baseline result above or the real Steam matrix. + ## Temporary local development During package development only, add this package to a consumer project's `Packages/manifest.json` with a `file:` URL and add `com.zerogamestudio.zeroengine.multiplayer` to `testables` when package tests need to run. diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs index 51099c04..b4373c0a 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerContracts.cs @@ -61,11 +61,16 @@ Task StopAsync( public interface IMultiplayerGameAdapter { CompatibilityDescriptor GetCompatibility(); + CompatibilityDescriptor GetCompatibility(string gameRoomId); Task PrepareSessionAsync( MultiplayerSessionContext context, CancellationToken cancellationToken); + Task SynchronizeLocalAsync( + MultiplayerSessionContext context, + CancellationToken cancellationToken); + Task SynchronizePeerAsync( MultiplayerPeer peer, CancellationToken cancellationToken); diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs index 1fd9d309..771d9910 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionCoordinator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -15,6 +16,8 @@ public sealed class MultiplayerSessionCoordinator : IAsyncDisposable private readonly OperationGenerationGate _operationGeneration = new OperationGenerationGate(); private readonly InviteRouter _inviteRouter = new InviteRouter(); private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1); + private readonly Dictionary _peerReconnectExpiry = + new Dictionary(); private RoomSnapshot _room; private OperationResult _lastResult = OperationResult.Success(); @@ -24,6 +27,8 @@ public sealed class MultiplayerSessionCoordinator : IAsyncDisposable private bool _intentionalLeave; private bool _disposed; private TimeSpan _reconnectRemaining; + private int _reconnectAttempt; + private CancellationTokenSource _activeReconnectCancellation; public MultiplayerSessionCoordinator( MultiplayerSessionConfig config, @@ -60,7 +65,8 @@ public MultiplayerSessionSnapshot GetSnapshot() _operationInProgress, _retryOperation, _lastResult, - _reconnectRemaining); + _reconnectRemaining, + _reconnectAttempt); } public Task InitializeAsync(CancellationToken cancellationToken) @@ -157,6 +163,26 @@ public async Task> CreateRoomAsync(CancellationTok } _room = create.Value; + OperationResult prepare = await RunWithTimeout( + token => _game.PrepareSessionAsync( + new MultiplayerSessionContext(_room, _platform.LocalUser, true), + token), + _config.InitialSyncTimeout, + cancellationToken, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.host_prepare_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!prepare.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(prepare); + } + transition = Transition(SessionPhase.Connecting); if (!transition.Succeeded) { @@ -183,26 +209,6 @@ public async Task> CreateRoomAsync(CancellationTok return FailRoomOperation(start); } - OperationResult prepare = await RunWithTimeout( - token => _game.PrepareSessionAsync( - new MultiplayerSessionContext(_room, _platform.LocalUser, true), - token), - _config.InitialSyncTimeout, - cancellationToken, - MultiplayerErrorCode.SynchronizationFailed, - "multiplayer.error.host_prepare_failed"); - - if (!IsCurrent(generation)) - { - return StaleRoomResult(); - } - - if (!prepare.Succeeded) - { - await CleanupFailedJoinAsync(); - return FailRoomOperation(prepare); - } - _room = _room .WithMemberPhase(_platform.LocalUser.Id, MemberConnectionPhase.Ready) .WithSession(_room.SessionGeneration, SessionPhase.InRoom, false, true); @@ -301,7 +307,7 @@ public async Task> JoinRoomAsync( CompatibilityDescriptor localCompatibility; try { - localCompatibility = _game.GetCompatibility(); + localCompatibility = _game.GetCompatibility(_room.GameRoomId); } catch (Exception exception) { @@ -323,6 +329,26 @@ public async Task> JoinRoomAsync( return FailRoomOperation(compatibility); } + OperationResult prepare = await RunWithTimeout( + token => _game.PrepareSessionAsync( + new MultiplayerSessionContext(_room, _platform.LocalUser, false), + token), + _config.InitialSyncTimeout, + cancellationToken, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.client_prepare_failed"); + + if (!IsCurrent(generation)) + { + return StaleRoomResult(); + } + + if (!prepare.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(prepare); + } + transition = Transition(SessionPhase.Connecting); if (!transition.Succeeded) { @@ -355,27 +381,42 @@ public async Task> JoinRoomAsync( } Transition(SessionPhase.Synchronizing); - OperationResult prepare = await RunWithTimeout( - token => _game.PrepareSessionAsync( + OperationResult synchronize = await RunWithTimeout( + token => _game.SynchronizeLocalAsync( new MultiplayerSessionContext(_room, _platform.LocalUser, false), token), _config.InitialSyncTimeout, cancellationToken, MultiplayerErrorCode.SynchronizationFailed, - "multiplayer.error.client_prepare_failed"); + "multiplayer.error.client_sync_failed"); if (!IsCurrent(generation)) { return StaleRoomResult(); } - if (!prepare.Succeeded) + if (!synchronize.Succeeded) { await CleanupFailedJoinAsync(); - return FailRoomOperation(prepare); + return FailRoomOperation(synchronize); + } + + _room = _room.WithMemberPhase(_platform.LocalUser.Id, MemberConnectionPhase.Ready); + SessionPhase target = _room.HasStarted || _room.Phase == SessionPhase.InGame + ? SessionPhase.InGame + : SessionPhase.Ready; + _room = _room.WithSession( + _room.SessionGeneration, + target, + target == SessionPhase.InGame, + _room.IsJoinable); + transition = Transition(target); + if (!transition.Succeeded) + { + await CleanupFailedJoinAsync(); + return FailRoomOperation(transition); } - _room = _room.WithMemberPhase(_platform.LocalUser.Id, MemberConnectionPhase.Synchronizing); _retryOperation = RetryOperationKind.None; SetLastResult(OperationResult.Success()); return OperationResult.Success(_room); @@ -421,6 +462,20 @@ public async Task> AcceptJoinRequestAsync(Cancella return await JoinRoomAsync(request.RoomId, cancellationToken); } + public OperationResult DismissJoinRequest() + { + if (!_inviteRouter.HasPending) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.no_pending_invite")); + } + + _inviteRouter.Clear(); + NotifyChanged(); + return SetLastResult(OperationResult.Success()); + } + public OperationResult Invite() { if (_room == null || !_room.IsHost(_platform.LocalUser.Id) || @@ -560,6 +615,52 @@ public OperationResult ConfirmLocalSynchronization(SessionId sessionId, long ses return SetLastResult(transition); } + public OperationResult ConfirmRemoteSessionStarted( + SessionId sessionId, + long sessionGeneration) + { + if (_driver.IsServer || _room == null || _room.IsHost(_platform.LocalUser.Id)) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.remote_start_confirmation_unavailable")); + } + + bool validGeneration = + (Phase == SessionPhase.Ready && + sessionGeneration == _room.SessionGeneration + 1) || + (Phase == SessionPhase.Starting && + sessionGeneration == _room.SessionGeneration) || + (Phase == SessionPhase.InGame && + sessionGeneration == _room.SessionGeneration); + if (_room.SessionId != sessionId || !validGeneration) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.SessionMismatch, + "multiplayer.error.remote_start_session_mismatch")); + } + + if (Phase == SessionPhase.InGame) + { + return SetLastResult(OperationResult.Success()); + } + + if (Phase != SessionPhase.Ready && Phase != SessionPhase.Starting) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.remote_start_confirmation_unexpected")); + } + + _room = _room.WithSession( + sessionGeneration, + SessionPhase.InGame, + true, + _config.AllowJoinInProgress); + OperationResult transition = Transition(SessionPhase.InGame); + return SetLastResult(transition); + } + public async Task StartGameAsync(CancellationToken cancellationToken) { OperationResult busy; @@ -716,6 +817,11 @@ public async Task RestorePeerAsync( if (_room != null) { _room = _room.WithMemberPhase(peer.User.Id, MemberConnectionPhase.Ready); + OperationResult publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + return SetLastResult(publish); + } } return SetLastResult(OperationResult.Success()); @@ -726,6 +832,198 @@ public async Task RestorePeerAsync( } } + public async Task ReconnectClientAsync(CancellationToken cancellationToken) + { + if (Phase != SessionPhase.Reconnecting || _driver.IsServer || _room == null) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.error.client_reconnect_not_available")); + } + + OperationResult busy; + if (!TryBeginOperation(out busy)) + { + return busy; + } + + CancellationTokenSource reconnectCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _activeReconnectCancellation = reconnectCancellation; + try + { + int generation = _operationGeneration.Begin(); + RoomSnapshot reconnectRoom = _room; + ReconnectPolicy policy = _config.CreateReconnectPolicy(); + Stopwatch stopwatch = Stopwatch.StartNew(); + OperationResult lastFailure = OperationResult.Failure( + MultiplayerErrorCode.TransportFailed, + "multiplayer.error.connection_lost"); + + for (int attemptIndex = 0; attemptIndex < policy.MaxAttempts; attemptIndex++) + { + if (!IsCurrent(generation) || Phase != SessionPhase.Reconnecting) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.reconnect_cancelled")); + } + + ReconnectBlockReason block = policy.Evaluate( + attemptIndex, + stopwatch.Elapsed, + reconnectCancellation.IsCancellationRequested, + out ReconnectAttempt attempt); + if (block != ReconnectBlockReason.None) + { + break; + } + + _reconnectAttempt = attempt.Number; + UpdateReconnectElapsed(stopwatch.Elapsed); + try + { + if (attempt.Delay > TimeSpan.Zero) + { + await Task.Delay(attempt.Delay, reconnectCancellation.Token); + } + } + catch (OperationCanceledException) + { + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.Cancelled, + "multiplayer.error.reconnect_cancelled")); + } + + UpdateReconnectElapsed(stopwatch.Elapsed); + OperationResult refresh = await RunWithTimeout( + token => _platform.RefreshAsync(token), + attempt.Timeout, + reconnectCancellation.Token, + MultiplayerErrorCode.RoomNotFound, + "multiplayer.error.reconnect_refresh_failed"); + if (!refresh.Succeeded) + { + lastFailure = refresh.Result; + if (refresh.ErrorCode == MultiplayerErrorCode.HostLeft) + { + EnterHostLeftRecovery(); + return SetLastResult(refresh.Result); + } + + continue; + } + + RoomSnapshot refreshedRoom = refresh.Value; + if (refreshedRoom == null || refreshedRoom.HostId != reconnectRoom.HostId || + refreshedRoom.SessionId != reconnectRoom.SessionId || + refreshedRoom.SessionGeneration > reconnectRoom.SessionGeneration) + { + EnterHostLeftRecovery(); + return SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.HostLeft, + "multiplayer.error.reconnect_session_replaced")); + } + + if (refreshedRoom.SessionGeneration == reconnectRoom.SessionGeneration) + { + _room = MergeConnectionPhases(reconnectRoom, refreshedRoom).WithSession( + reconnectRoom.SessionGeneration, + reconnectRoom.Phase, + reconnectRoom.HasStarted, + reconnectRoom.IsJoinable); + } + else + { + _room = reconnectRoom; + } + + MultiplayerSessionContext context = new MultiplayerSessionContext( + _room, + _platform.LocalUser, + false); + OperationResult prepare = await RunWithTimeout( + token => _game.PrepareSessionAsync(context, token), + attempt.Timeout, + reconnectCancellation.Token, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.reconnect_prepare_failed"); + if (!prepare.Succeeded) + { + lastFailure = prepare; + continue; + } + + OperationResult start = await RunWithTimeout( + token => _driver.StartClientAsync( + new ClientConnectionOptions( + _room.Id, + _room.HostId, + _room.SessionId, + _room.SessionGeneration, + _room.ConnectionAddress), + token), + attempt.Timeout, + reconnectCancellation.Token, + MultiplayerErrorCode.TransportFailed, + "multiplayer.error.reconnect_transport_failed"); + if (!start.Succeeded) + { + lastFailure = start; + continue; + } + + if (Phase == SessionPhase.Reconnecting) + { + Transition(SessionPhase.Synchronizing); + } + + OperationResult synchronize = await RunWithTimeout( + token => _game.SynchronizeLocalAsync(context, token), + Minimum(attempt.Timeout, _config.InitialSyncTimeout), + reconnectCancellation.Token, + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.reconnect_sync_failed"); + if (synchronize.Succeeded) + { + _reconnectAttempt = 0; + _reconnectRemaining = TimeSpan.Zero; + return ConfirmLocalSynchronization(_room.SessionId, _room.SessionGeneration); + } + + lastFailure = synchronize; + await RunWithTimeout( + token => _driver.StopAsync(DisconnectIntent.Failure, token), + _config.LeaveTimeout, + CancellationToken.None, + MultiplayerErrorCode.LeaveFailed, + "multiplayer.error.reconnect_cleanup_failed"); + if (Phase == SessionPhase.Synchronizing) + { + Transition(SessionPhase.Reconnecting); + } + } + + _reconnectAttempt = policy.MaxAttempts; + if (Phase == SessionPhase.Reconnecting) + { + return ExpireReconnect(); + } + + return SetLastResult(lastFailure); + } + finally + { + if (ReferenceEquals(_activeReconnectCancellation, reconnectCancellation)) + { + _activeReconnectCancellation = null; + } + + reconnectCancellation.Dispose(); + EndOperation(); + } + } + public Task> RetryAsync(CancellationToken cancellationToken) { switch (_retryOperation) @@ -750,8 +1048,10 @@ public OperationResult CancelReconnect() "multiplayer.error.reconnect_not_active")); } + _activeReconnectCancellation?.Cancel(); _operationGeneration.Invalidate(); _reconnectRemaining = TimeSpan.Zero; + _reconnectAttempt = 0; Transition(SessionPhase.Recovery); return SetLastResult(OperationResult.Failure( MultiplayerErrorCode.Cancelled, @@ -769,6 +1069,7 @@ public OperationResult ExpireReconnect() _operationGeneration.Invalidate(); _reconnectRemaining = TimeSpan.Zero; + _reconnectAttempt = 0; Transition(SessionPhase.Recovery); return SetLastResult(OperationResult.Failure( MultiplayerErrorCode.ReconnectExpired, @@ -850,6 +1151,7 @@ public async Task LeaveAsync(CancellationToken cancellationToke _retryRoomId = default(RoomId); _inviteRouter.Clear(); _reconnectRemaining = TimeSpan.Zero; + _reconnectAttempt = 0; Transition(SessionPhase.Idle); OperationResult result = !stop.Succeeded ? stop : leave; @@ -869,6 +1171,14 @@ public async ValueTask DisposeAsync() } _disposed = true; + _activeReconnectCancellation?.Cancel(); + _activeReconnectCancellation = null; + var expiries = new List(_peerReconnectExpiry.Values); + _peerReconnectExpiry.Clear(); + foreach (CancellationTokenSource expiry in expiries) + { + expiry.Cancel(); + } _stateMachine.PhaseChanged -= OnPhaseChanged; _platform.RoomEvent -= OnRoomEvent; _platform.JoinRequested -= OnJoinRequested; @@ -998,6 +1308,11 @@ private static OperationResult StaleResult() "multiplayer.error.stale_operation"); } + private static TimeSpan Minimum(TimeSpan left, TimeSpan right) + { + return left <= right ? left : right; + } + private OperationResult SetLastResult(OperationResult result) { _lastResult = result; @@ -1060,15 +1375,39 @@ private void OnJoinRequested(JoinRequest request) private void OnRoomEvent(PlatformRoomEvent roomEvent) { - if (_room == null || roomEvent.RoomId != _room.Id || - roomEvent.SessionGeneration != _room.SessionGeneration) + if (_room == null || roomEvent.RoomId != _room.Id) { return; } + if (roomEvent.SessionGeneration != _room.SessionGeneration && + !IsValidRemoteGenerationAdvance(roomEvent.Snapshot)) + { + return; + } + + if (roomEvent.Type == RoomEventType.HostLeft) + { + EnterHostLeftRecovery(); + NotifyChanged(); + return; + } + + if (roomEvent.Type == RoomEventType.MemberLeft && Phase == SessionPhase.InGame) + { + MultiplayerPeer disconnectedPeer; + if (TryGetPeer(roomEvent.MemberId, out disconnectedPeer)) + { + MarkPeerDisconnected(disconnectedPeer); + } + + return; + } + if (roomEvent.Snapshot != null) { _room = MergeConnectionPhases(_room, roomEvent.Snapshot); + ApplyRemoteRoomPhase(); } if (roomEvent.Type == RoomEventType.MemberLeft) @@ -1088,12 +1427,53 @@ private void OnRoomEvent(PlatformRoomEvent roomEvent) } } } - else if (roomEvent.Type == RoomEventType.HostLeft) + NotifyChanged(); + } + + private void ApplyRemoteRoomPhase() + { + if (_room == null || _room.IsHost(_platform.LocalUser.Id)) { - EnterHostLeftRecovery(); + return; } - NotifyChanged(); + if (_room.Phase == SessionPhase.Starting && Phase == SessionPhase.Ready) + { + Transition(SessionPhase.Starting); + } + else if (_room.Phase == SessionPhase.InGame && + (Phase == SessionPhase.Ready || Phase == SessionPhase.Starting || + Phase == SessionPhase.Synchronizing)) + { + Transition(SessionPhase.InGame); + } + else if (_room.Phase == SessionPhase.Ready && Phase == SessionPhase.Starting) + { + Transition(SessionPhase.Ready); + } + else if (_room.Phase == SessionPhase.Failed && + MultiplayerSessionStateMachine.CanTransition(Phase, SessionPhase.Failed)) + { + Transition(SessionPhase.Failed); + SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.remote_session_failed")); + } + } + + private bool IsValidRemoteGenerationAdvance(RoomSnapshot incoming) + { + if (incoming == null || _room == null || _room.IsHost(_platform.LocalUser.Id) || + incoming.Id != _room.Id || incoming.SessionId != _room.SessionId || + incoming.SessionGeneration <= _room.SessionGeneration) + { + return false; + } + + return incoming.Phase == SessionPhase.Starting || + incoming.Phase == SessionPhase.InGame || + incoming.Phase == SessionPhase.Ready || + incoming.Phase == SessionPhase.Failed; } private static RoomSnapshot MergeConnectionPhases( @@ -1192,10 +1572,31 @@ private void OnConnectionEvent(ConnectionEvent connectionEvent) return; } + if (connectionEvent.Type == ConnectionEventType.PeerConnected && _driver.IsServer) + { + MultiplayerPeer connectedPeer; + if (TryGetPeer(connectionEvent.UserId, out connectedPeer)) + { + CancelPeerReconnectExpiry(connectedPeer.User.Id); + if (Phase == SessionPhase.InGame) + { + _ = SynchronizeConnectedPeerAsync(connectedPeer, true); + } + else if (Phase == SessionPhase.InRoom || Phase == SessionPhase.Ready) + { + _ = SynchronizeConnectedPeerAsync(connectedPeer, false); + } + } + + return; + } + + bool localIsHost = _room != null && _room.IsHost(_platform.LocalUser.Id); if (connectionEvent.Type == ConnectionEventType.LocalDisconnected && - Phase == SessionPhase.InGame && _config.ReconnectEnabled) + Phase == SessionPhase.InGame && _config.ReconnectEnabled && !localIsHost) { _reconnectRemaining = _config.ReconnectHardDeadline; + _reconnectAttempt = 0; Transition(SessionPhase.Reconnecting); SetLastResult(OperationResult.Failure( MultiplayerErrorCode.TransportFailed, @@ -1203,6 +1604,12 @@ private void OnConnectionEvent(ConnectionEvent connectionEvent) ? "multiplayer.error.connection_lost" : connectionEvent.MessageKey)); } + else if ((connectionEvent.Type == ConnectionEventType.LocalDisconnected || + connectionEvent.Type == ConnectionEventType.Failed) && + Phase == SessionPhase.InGame && localIsHost) + { + EnterTransportRecovery(connectionEvent); + } else if (connectionEvent.Type == ConnectionEventType.ClientConnected && Phase == SessionPhase.Reconnecting) { @@ -1214,16 +1621,120 @@ private void OnConnectionEvent(ConnectionEvent connectionEvent) MultiplayerPeer peer; if (TryGetPeer(connectionEvent.UserId, out peer)) { - _room = _room.WithMemberPhase(peer.User.Id, MemberConnectionPhase.Disconnected); - OperationResult publish = PublishCurrentRoomState(); - if (!publish.Succeeded) - { - SetLastResult(publish); - } + MarkPeerDisconnected(peer); + } + } + } + + private void EnterTransportRecovery(ConnectionEvent connectionEvent) + { + if (!MultiplayerSessionStateMachine.CanTransition(Phase, SessionPhase.Recovery)) + { + return; + } + + _reconnectRemaining = TimeSpan.Zero; + _reconnectAttempt = 0; + Transition(SessionPhase.Recovery); + SetLastResult(OperationResult.Failure( + connectionEvent.ErrorCode == MultiplayerErrorCode.None + ? MultiplayerErrorCode.TransportFailed + : connectionEvent.ErrorCode, + string.IsNullOrWhiteSpace(connectionEvent.MessageKey) + ? "multiplayer.error.host_transport_failed" + : connectionEvent.MessageKey)); + } - _game.OnPeerDisconnected(peer, _config.ReconnectGracePeriod); - NotifyChanged(); + private async Task SynchronizeConnectedPeerAsync(MultiplayerPeer peer, bool restoring) + { + try + { + if (restoring) + { + await RestorePeerAsync(peer, CancellationToken.None); } + else + { + await SynchronizePeerAsync(peer, CancellationToken.None); + } + } + catch (Exception exception) + { + SetLastResult(OperationResult.Failure( + MultiplayerErrorCode.SynchronizationFailed, + "multiplayer.error.peer_sync_exception", + exception.GetType().Name)); + } + } + + private void MarkPeerDisconnected(MultiplayerPeer peer) + { + if (_room == null || Phase != SessionPhase.InGame) + { + return; + } + + _room = _room.WithMemberPhase(peer.User.Id, MemberConnectionPhase.Disconnected); + OperationResult publish = PublishCurrentRoomState(); + if (!publish.Succeeded) + { + SetLastResult(publish); + } + + _game.OnPeerDisconnected(peer, _config.ReconnectGracePeriod); + SchedulePeerReconnectExpiry(peer); + NotifyChanged(); + } + + private void SchedulePeerReconnectExpiry(MultiplayerPeer peer) + { + CancelPeerReconnectExpiry(peer.User.Id); + var cancellation = new CancellationTokenSource(); + _peerReconnectExpiry[peer.User.Id] = cancellation; + _ = ExpirePeerReconnectAfterDelayAsync( + peer, + _room == null ? default(SessionId) : _room.SessionId, + _room == null ? 0 : _room.SessionGeneration, + cancellation); + } + + private async Task ExpirePeerReconnectAfterDelayAsync( + MultiplayerPeer peer, + SessionId sessionId, + long sessionGeneration, + CancellationTokenSource cancellation) + { + try + { + await Task.Delay(_config.ReconnectGracePeriod, cancellation.Token); + if (!cancellation.IsCancellationRequested && _room != null && + _room.SessionId == sessionId && _room.SessionGeneration == sessionGeneration && + Phase == SessionPhase.InGame) + { + ExpirePeerReconnect(peer); + } + } + catch (OperationCanceledException) + { + } + finally + { + if (_peerReconnectExpiry.TryGetValue(peer.User.Id, out CancellationTokenSource current) && + ReferenceEquals(current, cancellation)) + { + _peerReconnectExpiry.Remove(peer.User.Id); + } + + cancellation.Dispose(); + } + } + + private void CancelPeerReconnectExpiry(PlatformUserId userId) + { + if (_peerReconnectExpiry.TryGetValue(userId, out CancellationTokenSource cancellation)) + { + _peerReconnectExpiry.Remove(userId); + cancellation.Cancel(); } } @@ -1253,9 +1764,11 @@ private void EnterHostLeftRecovery() Transition(SessionPhase.Reconnecting); } - if (Phase == SessionPhase.Reconnecting) + if (Phase == SessionPhase.Reconnecting || + MultiplayerSessionStateMachine.CanTransition(Phase, SessionPhase.Recovery)) { _reconnectRemaining = TimeSpan.Zero; + _reconnectAttempt = 0; Transition(SessionPhase.Recovery); SetLastResult(OperationResult.Failure( MultiplayerErrorCode.HostLeft, diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs index b658ab0a..2d2533df 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Core/MultiplayerSessionRules.cs @@ -53,20 +53,24 @@ public static bool CanTransition(SessionPhase source, SessionPhase target) return target == SessionPhase.Connecting || target == SessionPhase.Failed; case SessionPhase.Connecting: return target == SessionPhase.InRoom || target == SessionPhase.Synchronizing || - target == SessionPhase.Failed; + target == SessionPhase.Recovery || target == SessionPhase.Failed; case SessionPhase.InRoom: - return target == SessionPhase.Synchronizing || target == SessionPhase.Leaving; + return target == SessionPhase.Synchronizing || target == SessionPhase.Recovery || + target == SessionPhase.Leaving; case SessionPhase.Synchronizing: return target == SessionPhase.Ready || target == SessionPhase.InRoom || - target == SessionPhase.InGame || target == SessionPhase.Failed; + target == SessionPhase.InGame || target == SessionPhase.Reconnecting || + target == SessionPhase.Recovery || target == SessionPhase.Failed; case SessionPhase.Ready: return target == SessionPhase.Starting || target == SessionPhase.InRoom || - target == SessionPhase.Leaving; + target == SessionPhase.InGame || target == SessionPhase.Recovery || + target == SessionPhase.Leaving || target == SessionPhase.Failed; case SessionPhase.Starting: return target == SessionPhase.InGame || target == SessionPhase.Ready || - target == SessionPhase.Failed; + target == SessionPhase.Recovery || target == SessionPhase.Failed; case SessionPhase.InGame: - return target == SessionPhase.Reconnecting || target == SessionPhase.Leaving; + return target == SessionPhase.Reconnecting || target == SessionPhase.Recovery || + target == SessionPhase.Leaving; case SessionPhase.Reconnecting: return target == SessionPhase.Synchronizing || target == SessionPhase.Recovery; case SessionPhase.Recovery: @@ -477,7 +481,8 @@ public MultiplayerSessionSnapshot( bool operationInProgress, RetryOperationKind retryOperation, OperationResult lastResult, - TimeSpan reconnectRemaining) + TimeSpan reconnectRemaining, + int reconnectAttempt = 0) { Phase = phase; Room = room; @@ -487,6 +492,7 @@ public MultiplayerSessionSnapshot( RetryOperation = retryOperation; LastResult = lastResult; ReconnectRemaining = reconnectRemaining; + ReconnectAttempt = reconnectAttempt; } public SessionPhase Phase { get; } @@ -497,5 +503,6 @@ public MultiplayerSessionSnapshot( public RetryOperationKind RetryOperation { get; } public OperationResult LastResult { get; } public TimeSpan ReconnectRemaining { get; } + public int ReconnectAttempt { get; } } } diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs index 227353ce..115467bc 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/FishNet/FishNetConnectionDriver.cs @@ -5,6 +5,7 @@ using FishNet.Connection; using FishNet.Managing; using FishNet.Transporting; +using FishNet.Transporting.Multipass; using UnityEngine; namespace ZeroEngine.Multiplayer.FishNet @@ -13,6 +14,7 @@ public sealed class FishNetConnectionDriver : MonoBehaviour, INetworkConnectionD { [SerializeField] private NetworkManager networkManager; [SerializeField] private Transport transport; + [SerializeField] private Transport routedTransport; [SerializeField] private FishNetIdentityBridge identityBridge; [SerializeField] private TransportMode transportMode = TransportMode.LocalDirect; [SerializeField] private string localAddress = "127.0.0.1"; @@ -26,11 +28,14 @@ public sealed class FishNetConnectionDriver : MonoBehaviour, INetworkConnectionD private bool _wasServerActive; private PlatformUserId _activeHostId; private IRemoteConnectionAuthorizer _remoteAuthorizer; + private int _multipassTransportIndex = -1; public ConnectionPhase Phase { get; private set; } = ConnectionPhase.Stopped; public bool IsServer => networkManager != null && networkManager.IsServerStarted; public bool IsClient => networkManager != null && networkManager.IsClientStarted; public TransportMode TransportMode => transportMode; + public Transport ActiveTransport => transport; + public Transport RoutedTransport => routedTransport; public string LocalAddress => localAddress ?? string.Empty; public ushort Port => port; public bool HasRemoteAuthorizer => _remoteAuthorizer != null; @@ -55,15 +60,36 @@ public void Configure( TransportMode mode, string address, ushort selectedPort) + { + Configure( + manager, + selectedTransport, + selectedTransport, + selectedIdentityBridge, + mode, + address, + selectedPort); + } + + public void Configure( + NetworkManager manager, + Transport activeTransport, + Transport selectedRoute, + FishNetIdentityBridge selectedIdentityBridge, + TransportMode mode, + string address, + ushort selectedPort) { Unsubscribe(); networkManager = manager; - transport = selectedTransport; + transport = activeTransport; + routedTransport = selectedRoute; identityBridge = selectedIdentityBridge; transportMode = mode; localAddress = string.IsNullOrWhiteSpace(address) ? "127.0.0.1" : address.Trim(); port = selectedPort; ResolveReferences(); + ConfigureMultipassRoute(); Subscribe(); } @@ -90,6 +116,12 @@ public OperationResult ValidateSetup() "multiplayer.fishnet.active_transport_mismatch"); } + OperationResult route = ConfigureMultipassRoute(); + if (!route.Succeeded) + { + return route; + } + if (identityBridge == null || port == 0 || string.IsNullOrWhiteSpace(localAddress)) { return OperationResult.Failure( @@ -134,12 +166,11 @@ public async Task StartHostAsync( Phase = ConnectionPhase.StartingHost; _intentionalStop = false; _remoteUsers.Clear(); - transport.SetPort(port); - transport.SetClientAddress(localAddress); + ConfigureSelectedTransport(localAddress); try { - if (!networkManager.IsServerStarted && !transport.StartConnection(true)) + if (!networkManager.IsServerStarted && !StartSelectedConnection(true)) { return Fail("multiplayer.fishnet.server_start_rejected"); } @@ -148,7 +179,7 @@ public async Task StartHostAsync( _wasServerActive = true; _hostLocalClientStarting = true; - if (!networkManager.IsClientStarted && !transport.StartConnection(false)) + if (!networkManager.IsClientStarted && !StartSelectedConnection(false)) { await StopStartedTransportsAsync(); return Fail("multiplayer.fishnet.host_client_start_rejected"); @@ -206,12 +237,11 @@ public async Task StartClientAsync( Phase = ConnectionPhase.StartingClient; _intentionalStop = false; _activeHostId = options.HostId; - transport.SetPort(port); - transport.SetClientAddress(address); + ConfigureSelectedTransport(address); try { - if (!networkManager.IsClientStarted && !transport.StartConnection(false)) + if (!networkManager.IsClientStarted && !StartSelectedConnection(false)) { return Fail("multiplayer.fishnet.client_start_rejected"); } @@ -250,19 +280,19 @@ public async Task StopAsync( _intentionalStop = true; try { - if (transport.GetConnectionState(false) != LocalConnectionState.Stopped) + if (GetSelectedConnectionState(false) != LocalConnectionState.Stopped) { - transport.StopConnection(false); + StopSelectedConnection(false); await WaitUntilAsync( - () => transport.GetConnectionState(false) == LocalConnectionState.Stopped, + () => GetSelectedConnectionState(false) == LocalConnectionState.Stopped, cancellationToken); } - if (transport.GetConnectionState(true) != LocalConnectionState.Stopped) + if (GetSelectedConnectionState(true) != LocalConnectionState.Stopped) { - transport.StopConnection(true); + StopSelectedConnection(true); await WaitUntilAsync( - () => transport.GetConnectionState(true) == LocalConnectionState.Stopped, + () => GetSelectedConnectionState(true) == LocalConnectionState.Stopped, cancellationToken); } @@ -301,6 +331,11 @@ private void ResolveReferences() transport = networkManager.TransportManager.Transport; } + if (routedTransport == null) + { + routedTransport = transport; + } + if (identityBridge == null) { identityBridge = GetComponentInParent(); @@ -461,14 +496,14 @@ private async Task StopStartedTransportsAsync() _intentionalStop = true; try { - if (transport != null && transport.GetConnectionState(false) != LocalConnectionState.Stopped) + if (transport != null && GetSelectedConnectionState(false) != LocalConnectionState.Stopped) { - transport.StopConnection(false); + StopSelectedConnection(false); } - if (transport != null && transport.GetConnectionState(true) != LocalConnectionState.Stopped) + if (transport != null && GetSelectedConnectionState(true) != LocalConnectionState.Stopped) { - transport.StopConnection(true); + StopSelectedConnection(true); } await Task.Yield(); @@ -490,6 +525,99 @@ private static async Task WaitUntilAsync(Func condition, CancellationToken } } + private OperationResult ConfigureMultipassRoute() + { + _multipassTransportIndex = -1; + if (transport == null) + { + return OperationResult.Failure( + MultiplayerErrorCode.TransportUnavailable, + "multiplayer.fishnet.transport_missing"); + } + + if (!(transport is Multipass multipass)) + { + if (routedTransport == null) + { + routedTransport = transport; + } + + return ReferenceEquals(transport, routedTransport) + ? OperationResult.Success() + : OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.route_requires_multipass"); + } + + if (routedTransport == null) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.routed_transport_missing"); + } + + for (int index = 0; index < multipass.Transports.Count; index++) + { + if (ReferenceEquals(multipass.Transports[index], routedTransport)) + { + _multipassTransportIndex = index; + break; + } + } + + if (_multipassTransportIndex < 0) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.routed_transport_not_registered"); + } + + if (multipass.GlobalServerActions) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidConfiguration, + "multiplayer.fishnet.multipass_global_server_actions_enabled"); + } + + multipass.SetClientTransport(_multipassTransportIndex); + return OperationResult.Success(); + } + + private void ConfigureSelectedTransport(string address) + { + if (transport is Multipass multipass) + { + multipass.SetClientTransport(_multipassTransportIndex); + multipass.SetPort(port, _multipassTransportIndex); + multipass.SetClientAddress(address, _multipassTransportIndex); + return; + } + + transport.SetPort(port); + transport.SetClientAddress(address); + } + + private bool StartSelectedConnection(bool server) + { + return transport is Multipass multipass + ? multipass.StartConnection(server, _multipassTransportIndex) + : transport.StartConnection(server); + } + + private bool StopSelectedConnection(bool server) + { + return transport is Multipass multipass + ? multipass.StopConnection(server, _multipassTransportIndex) + : transport.StopConnection(server); + } + + private LocalConnectionState GetSelectedConnectionState(bool server) + { + return transport is Multipass multipass + ? multipass.GetConnectionState(server, _multipassTransportIndex) + : transport.GetConnectionState(server); + } + private OperationResult Fail(string messageKey, params string[] arguments) { Phase = ConnectionPhase.Failed; diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs index e2a427db..684f6d7e 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Presentation/MultiplayerViewState.cs @@ -17,7 +17,8 @@ private MultiplayerViewState( string errorMessageKey, IReadOnlyList errorArguments, string progressMessageKey, - TimeSpan reconnectRemaining) + TimeSpan reconnectRemaining, + int reconnectAttempt) { Phase = phase; Room = room; @@ -31,6 +32,7 @@ private MultiplayerViewState( ErrorArguments = errorArguments ?? Array.Empty(); ProgressMessageKey = progressMessageKey ?? string.Empty; ReconnectRemaining = reconnectRemaining; + ReconnectAttempt = reconnectAttempt; } public SessionPhase Phase { get; } @@ -45,6 +47,7 @@ private MultiplayerViewState( public IReadOnlyList ErrorArguments { get; } public string ProgressMessageKey { get; } public TimeSpan ReconnectRemaining { get; } + public int ReconnectAttempt { get; } public static MultiplayerViewState Create( MultiplayerSessionSnapshot snapshot, @@ -75,7 +78,8 @@ public static MultiplayerViewState Create( snapshot.LastResult.Succeeded ? string.Empty : snapshot.LastResult.MessageKey, snapshot.LastResult.Succeeded ? Array.Empty() : snapshot.LastResult.MessageArguments, GetProgressMessageKey(snapshot.Phase), - snapshot.ReconnectRemaining); + snapshot.ReconnectRemaining, + snapshot.ReconnectAttempt); } private static bool MeetsReadyConditions(RoomSnapshot room, int minimumPlayers) diff --git a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs index 57b66363..7e4a3aa1 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Runtime/Steam/SteamRuntimeOwner.cs @@ -26,6 +26,27 @@ public sealed class SteamRuntimeOwner : MonoBehaviour, ISteamRuntime public PlatformUser LocalUser => _localUser; public bool OwnsRuntime => _ownsRuntime; + public OperationResult Configure(uint configuredAppId, bool shouldRestartAppIfNecessary) + { + if (_initialized) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidState, + "multiplayer.steam.runtime_already_initialized"); + } + + if (configuredAppId == 0) + { + return OperationResult.Failure( + MultiplayerErrorCode.InvalidArgument, + "multiplayer.steam.app_id_invalid"); + } + + appId = configuredAppId; + restartAppIfNecessary = shouldRestartAppIfNecessary; + return OperationResult.Success(); + } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetStatics() { diff --git a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs index 017a3938..57006cb9 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Samples~/RoomUI/LocalRoomSampleController.cs @@ -80,6 +80,11 @@ public CompatibilityDescriptor GetCompatibility() : _options.Compatibility; } + public CompatibilityDescriptor GetCompatibility(string gameRoomId) + { + return GetCompatibility(); + } + public Task PrepareSessionAsync( MultiplayerSessionContext context, CancellationToken cancellationToken) @@ -87,6 +92,13 @@ public Task PrepareSessionAsync( return Task.FromResult(OperationResult.Success()); } + public Task SynchronizeLocalAsync( + MultiplayerSessionContext context, + CancellationToken cancellationToken) + { + return Task.FromResult(OperationResult.Success()); + } + public Task SynchronizePeerAsync( MultiplayerPeer peer, CancellationToken cancellationToken) diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs index 7421c678..47ccedb8 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerFakes.cs @@ -101,6 +101,11 @@ public ValueTask DisposeAsync() public void EmitRoomEvent(PlatformRoomEvent roomEvent) { + if (roomEvent.Snapshot != null) + { + CurrentRoom = roomEvent.Snapshot; + } + Action handler = RoomEvent; if (handler != null) { @@ -193,6 +198,17 @@ public Task StopAsync( public void Emit(ConnectionEvent connectionEvent) { + if (connectionEvent.Type == ConnectionEventType.LocalDisconnected || + connectionEvent.Type == ConnectionEventType.Failed) + { + Phase = ConnectionPhase.Failed; + IsClient = false; + if (connectionEvent.Type == ConnectionEventType.Failed) + { + IsServer = false; + } + } + Action handler = ConnectionEvent; if (handler != null) { @@ -207,9 +223,11 @@ internal sealed class FakeGameAdapter : IMultiplayerGameAdapter public CompatibilityDescriptor Compatibility { get; set; } = TestData.Compatibility; public OperationResult DefaultPrepareResult { get; set; } = OperationResult.Success(); + public OperationResult LocalSynchronizeResult { get; set; } = OperationResult.Success(); public OperationResult SynchronizeResult { get; set; } = OperationResult.Success(); public OperationResult RestoreResult { get; set; } = OperationResult.Success(); public int PrepareCalls { get; private set; } + public int LocalSynchronizeCalls { get; private set; } public int SynchronizeCalls { get; private set; } public int RestoreCalls { get; private set; } public int PeerDisconnectedCalls { get; private set; } @@ -221,6 +239,11 @@ public CompatibilityDescriptor GetCompatibility() return Compatibility; } + public CompatibilityDescriptor GetCompatibility(string gameRoomId) + { + return Compatibility; + } + public Task PrepareSessionAsync( MultiplayerSessionContext context, CancellationToken cancellationToken) @@ -231,6 +254,14 @@ public Task PrepareSessionAsync( : DefaultPrepareResult); } + public Task SynchronizeLocalAsync( + MultiplayerSessionContext context, + CancellationToken cancellationToken) + { + LocalSynchronizeCalls++; + return Task.FromResult(LocalSynchronizeResult); + } + public Task SynchronizePeerAsync( MultiplayerPeer peer, CancellationToken cancellationToken) diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs index 6764c09a..49bd93a0 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionCoordinatorTests.cs @@ -17,6 +17,12 @@ public sealed class MultiplayerSessionCoordinatorTests public void SetUp() { _config = ScriptableObject.CreateInstance(); + typeof(MultiplayerSessionConfig) + .GetField( + "reconnectRetryIntervalsSeconds", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + .SetValue(_config, new[] { 0f, 0f, 0f }); _platform = new FakePlatformRoomService { LocalUser = TestData.Host, @@ -182,7 +188,7 @@ public void JoinNonJoinableRoom_FailsBeforeStartingTransport() } [Test] - public void JoinSuccess_WaitsForAuthoritativeSynchronizationConfirmation() + public void JoinSuccess_PreparesConnectsAndSynchronizesBeforeReady() { _platform.LocalUser = TestData.Guest; Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); @@ -192,19 +198,9 @@ public void JoinSuccess_WaitsForAuthoritativeSynchronizationConfirmation() CancellationToken.None)); Assert.IsTrue(join.Succeeded); - Assert.AreEqual(SessionPhase.Synchronizing, _coordinator.Phase); - - OperationResult staleConfirmation = _coordinator.ConfirmLocalSynchronization( - join.Value.SessionId, - join.Value.SessionGeneration + 1); - Assert.AreEqual(MultiplayerErrorCode.SessionMismatch, staleConfirmation.ErrorCode); - Assert.AreEqual(SessionPhase.Synchronizing, _coordinator.Phase); - - OperationResult confirmation = _coordinator.ConfirmLocalSynchronization( - join.Value.SessionId, - join.Value.SessionGeneration); - Assert.IsTrue(confirmation.Succeeded); Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); + Assert.AreEqual(1, _game.PrepareCalls); + Assert.AreEqual(1, _game.LocalSynchronizeCalls); } [Test] @@ -221,7 +217,7 @@ public void ConfirmedInviteFromExistingRoom_LeavesThenJoinsRequestedRoom() Assert.IsTrue(result.Succeeded); Assert.AreEqual(1, _platform.LeaveCalls); Assert.AreEqual(1, _platform.JoinCalls); - Assert.AreEqual(SessionPhase.Synchronizing, _coordinator.Phase); + Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); } [Test] @@ -299,11 +295,11 @@ public void IntentionalLeave_DoesNotEnterReconnectAndEndsIdleEvenWhenCleanupFail [Test] public void UnexpectedDisconnect_EntersReconnectAndCanBeCancelledToRecovery() { - ReachInGameHost(); + ReachInGameGuest(); _driver.Emit(new ConnectionEvent( ConnectionEventType.LocalDisconnected, - TestData.Host.Id, + TestData.Guest.Id, MultiplayerErrorCode.TransportFailed, "test.connection_lost")); _coordinator.UpdateReconnectElapsed(System.TimeSpan.FromSeconds(3)); @@ -319,16 +315,16 @@ public void UnexpectedDisconnect_EntersReconnectAndCanBeCancelledToRecovery() [Test] public void HostLeftDuringReconnect_EndsInRecoveryImmediately() { - ReachInGameHost(); + ReachInGameGuest(); _driver.Emit(new ConnectionEvent( ConnectionEventType.LocalDisconnected, - TestData.Host.Id, + TestData.Guest.Id, MultiplayerErrorCode.TransportFailed, "test.connection_lost")); _driver.Emit(new ConnectionEvent( ConnectionEventType.LocalDisconnected, - TestData.Host.Id, + TestData.Guest.Id, MultiplayerErrorCode.HostLeft, "test.host_left")); @@ -336,6 +332,186 @@ public void HostLeftDuringReconnect_EndsInRecoveryImmediately() Assert.AreEqual(MultiplayerErrorCode.HostLeft, _coordinator.GetSnapshot().LastResult.ErrorCode); } + [Test] + public void ClientReconnect_RestoresSameSessionAndReturnsToInGame() + { + ReachInGameGuest(); + _driver.Emit(new ConnectionEvent( + ConnectionEventType.LocalDisconnected, + TestData.Guest.Id, + MultiplayerErrorCode.TransportFailed, + "test.connection_lost")); + + OperationResult result = Complete( + _coordinator.ReconnectClientAsync(CancellationToken.None)); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + Assert.AreEqual(2, _driver.StartClientCalls); + Assert.AreEqual(2, _game.PrepareCalls); + Assert.AreEqual(2, _game.LocalSynchronizeCalls); + Assert.AreEqual(0, _coordinator.GetSnapshot().ReconnectAttempt); + Assert.AreEqual(System.TimeSpan.Zero, _coordinator.GetSnapshot().ReconnectRemaining); + } + + [Test] + public void ClientReconnect_AcceptsStalePlatformDescriptorAfterAuthenticatedStart() + { + ReachReadyGuest(); + RoomSnapshot ready = _coordinator.CurrentRoom; + Assert.IsTrue(_coordinator.ConfirmRemoteSessionStarted( + ready.SessionId, + ready.SessionGeneration + 1).Succeeded); + Assert.Less( + _platform.CurrentRoom.SessionGeneration, + _coordinator.CurrentRoom.SessionGeneration); + _driver.Emit(new ConnectionEvent( + ConnectionEventType.LocalDisconnected, + TestData.Guest.Id, + MultiplayerErrorCode.TransportFailed, + "test.connection_lost")); + + OperationResult result = Complete( + _coordinator.ReconnectClientAsync(CancellationToken.None)); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + Assert.AreEqual( + ready.SessionGeneration + 1, + _coordinator.CurrentRoom.SessionGeneration); + } + + [Test] + public void ClientReconnect_AttemptsExhaustedReportsReconnectExpired() + { + ReachInGameGuest(); + _driver.Emit(new ConnectionEvent( + ConnectionEventType.LocalDisconnected, + TestData.Guest.Id, + MultiplayerErrorCode.TransportFailed, + "test.connection_lost")); + _driver.StartClientResult = OperationResult.Failure( + MultiplayerErrorCode.TransportFailed, + "test.reconnect_failed"); + + OperationResult result = Complete( + _coordinator.ReconnectClientAsync(CancellationToken.None)); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.ReconnectExpired, result.ErrorCode); + Assert.AreEqual(SessionPhase.Recovery, _coordinator.Phase); + Assert.AreEqual( + MultiplayerErrorCode.ReconnectExpired, + _coordinator.GetSnapshot().LastResult.ErrorCode); + Assert.AreEqual(4, _driver.StartClientCalls); + } + + [Test] + public void HostTransportFailure_EntersRecoveryWithoutClientReconnect() + { + ReachInGameHost(); + + _driver.Emit(new ConnectionEvent( + ConnectionEventType.Failed, + TestData.Host.Id, + MultiplayerErrorCode.TransportFailed, + "test.server_failed")); + + Assert.AreEqual(SessionPhase.Recovery, _coordinator.Phase); + Assert.AreEqual(MultiplayerErrorCode.TransportFailed, + _coordinator.GetSnapshot().LastResult.ErrorCode); + } + + [Test] + public void ClientAcceptsHostStartGenerationAdvance() + { + ReachReadyGuest(); + long generation = _coordinator.CurrentRoom.SessionGeneration + 1; + RoomSnapshot starting = _coordinator.CurrentRoom.WithSession( + generation, + SessionPhase.Starting, + false, + false); + _platform.EmitRoomEvent(new PlatformRoomEvent( + RoomEventType.RoomUpdated, + starting.Id, + starting.SessionGeneration, + starting, + default(PlatformUserId))); + Assert.AreEqual(SessionPhase.Starting, _coordinator.Phase); + + RoomSnapshot inGame = starting.WithSession( + generation, + SessionPhase.InGame, + true, + false); + _platform.EmitRoomEvent(new PlatformRoomEvent( + RoomEventType.RoomUpdated, + inGame.Id, + inGame.SessionGeneration, + inGame, + default(PlatformUserId))); + + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + Assert.AreEqual(generation, _coordinator.CurrentRoom.SessionGeneration); + } + + [Test] + public void ClientAcceptsAuthenticatedGameStartWithoutPlatformRoomUpdate() + { + ReachReadyGuest(); + RoomSnapshot ready = _coordinator.CurrentRoom; + long generation = ready.SessionGeneration + 1; + + OperationResult result = _coordinator.ConfirmRemoteSessionStarted( + ready.SessionId, + generation); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + Assert.AreEqual(generation, _coordinator.CurrentRoom.SessionGeneration); + Assert.IsTrue(_coordinator.CurrentRoom.HasStarted); + } + + [Test] + public void ClientRejectsAuthenticatedGameStartWithoutGenerationAdvance() + { + ReachReadyGuest(); + RoomSnapshot ready = _coordinator.CurrentRoom; + + OperationResult result = _coordinator.ConfirmRemoteSessionStarted( + ready.SessionId, + ready.SessionGeneration); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(MultiplayerErrorCode.SessionMismatch, result.ErrorCode); + Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); + } + + [Test] + public void ClientAcceptsHostFailedGenerationAdvance() + { + ReachReadyGuest(); + long generation = _coordinator.CurrentRoom.SessionGeneration + 1; + RoomSnapshot failed = _coordinator.CurrentRoom.WithSession( + generation, + SessionPhase.Failed, + false, + false); + + _platform.EmitRoomEvent(new PlatformRoomEvent( + RoomEventType.RoomUpdated, + failed.Id, + failed.SessionGeneration, + failed, + default(PlatformUserId))); + + Assert.AreEqual(SessionPhase.Failed, _coordinator.Phase); + Assert.AreEqual( + MultiplayerErrorCode.SynchronizationFailed, + _coordinator.GetSnapshot().LastResult.ErrorCode); + } + [Test] public void ServerPeerDisconnectAndExpiry_UseGameAdapterHooksWithoutChangingHostPhase() { @@ -421,6 +597,34 @@ private void ReachInGameHost() Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); } + private void ReachReadyGuest() + { + _platform.LocalUser = TestData.Guest; + Assert.IsTrue(Complete(_coordinator.InitializeAsync(CancellationToken.None)).Succeeded); + Assert.IsTrue(Complete(_coordinator.JoinRoomAsync( + new RoomId("room-1"), + CancellationToken.None)).Succeeded); + Assert.AreEqual(SessionPhase.Ready, _coordinator.Phase); + } + + private void ReachInGameGuest() + { + ReachReadyGuest(); + long generation = _coordinator.CurrentRoom.SessionGeneration + 1; + RoomSnapshot inGame = _coordinator.CurrentRoom.WithSession( + generation, + SessionPhase.InGame, + true, + false); + _platform.EmitRoomEvent(new PlatformRoomEvent( + RoomEventType.RoomUpdated, + inGame.Id, + inGame.SessionGeneration, + inGame, + default(PlatformUserId))); + Assert.AreEqual(SessionPhase.InGame, _coordinator.Phase); + } + private static T Complete(Task task) { return task.GetAwaiter().GetResult(); diff --git a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs index 491e189c..858c3da9 100644 --- a/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs +++ b/com.zerogamestudio.zeroengine.multiplayer/Tests/Editor/MultiplayerSessionRulesTests.cs @@ -23,20 +23,29 @@ public void StateMachine_OnlyAllowsDocumentedTransitions() Key(SessionPhase.JoiningRoom, SessionPhase.Failed), Key(SessionPhase.Connecting, SessionPhase.InRoom), Key(SessionPhase.Connecting, SessionPhase.Synchronizing), + Key(SessionPhase.Connecting, SessionPhase.Recovery), Key(SessionPhase.Connecting, SessionPhase.Failed), Key(SessionPhase.InRoom, SessionPhase.Synchronizing), + Key(SessionPhase.InRoom, SessionPhase.Recovery), Key(SessionPhase.InRoom, SessionPhase.Leaving), Key(SessionPhase.Synchronizing, SessionPhase.Ready), Key(SessionPhase.Synchronizing, SessionPhase.InRoom), Key(SessionPhase.Synchronizing, SessionPhase.InGame), + Key(SessionPhase.Synchronizing, SessionPhase.Reconnecting), + Key(SessionPhase.Synchronizing, SessionPhase.Recovery), Key(SessionPhase.Synchronizing, SessionPhase.Failed), Key(SessionPhase.Ready, SessionPhase.Starting), Key(SessionPhase.Ready, SessionPhase.InRoom), + Key(SessionPhase.Ready, SessionPhase.InGame), + Key(SessionPhase.Ready, SessionPhase.Recovery), + Key(SessionPhase.Ready, SessionPhase.Failed), Key(SessionPhase.Ready, SessionPhase.Leaving), Key(SessionPhase.Starting, SessionPhase.InGame), Key(SessionPhase.Starting, SessionPhase.Ready), + Key(SessionPhase.Starting, SessionPhase.Recovery), Key(SessionPhase.Starting, SessionPhase.Failed), Key(SessionPhase.InGame, SessionPhase.Reconnecting), + Key(SessionPhase.InGame, SessionPhase.Recovery), Key(SessionPhase.InGame, SessionPhase.Leaving), Key(SessionPhase.Reconnecting, SessionPhase.Synchronizing), Key(SessionPhase.Reconnecting, SessionPhase.Recovery),