From 42c3874d828c2765f86b52a1e10e1effc4b79f24 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Wed, 29 Jul 2026 19:30:50 +0800 Subject: [PATCH] fix: improve minigame reconnect flow --- .../Shared/Protocol/FrameworkMessages.cs | 49 ++++- .../Game/Art/UI/Prefab/UI_MatchWaiting.prefab | 6 +- Client/Assets/Script/Editor/XWorldUtil.cs | 166 +++++++++++++++- .../EditMode/MatchWaitingPrefabLayoutTests.cs | 30 +++ .../MatchWaitingPrefabLayoutTests.cs.meta | 11 + .../EditMode/MiniGameStageIsolationTests.cs | 47 +++++ .../XWorldJsonPrefabAutoConvertTests.cs | 82 ++++++++ .../Character/CharacterSwitchController.cs | 15 ++ .../Script/xmain/Client/CSharpClientApp.cs | 165 ++++++++++++++- .../Script/xmain/MiniGame/MiniGameHost.cs | 28 +++ .../xmain/MiniGame/MiniGameStageIsolation.cs | 42 ++++ .../FrameworkMessagesTests.cs | 31 ++- Server/Gateway.Runner/Program.cs | 9 +- .../ServerLoopMatchmakingTests.cs | 61 ++++++ Server/Gateway.Tests/ServerLoopTests.cs | 57 ++++++ Server/Gateway/GameServerHost.cs | 2 +- Server/Gateway/Matchmaker.cs | 10 +- Server/Gateway/RoomOutputSink.cs | 7 +- Server/Gateway/ServerLoop.cs | 188 +++++++++++++++--- Server/Server.Host.Tests/RoomHostTests.cs | 17 ++ Server/Server.Host/RoomHost.cs | 8 + 21 files changed, 979 insertions(+), 52 deletions(-) create mode 100644 Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs create mode 100644 Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs.meta diff --git a/Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs b/Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs index fecda49a..bd0189f7 100644 --- a/Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs +++ b/Client/Assets/Framework/Shared/Protocol/FrameworkMessages.cs @@ -23,6 +23,7 @@ namespace XWorld.Framework.Protocol LobbyLeave = 15, WorldChatSend = 16, WorldChatMessage = 17, + MatchCancel = 18, } public sealed class HeartbeatMsg @@ -47,19 +48,23 @@ namespace XWorld.Framework.Protocol { public string GameId; // null 编码为空字符串 public int Version; + public int RequestId; public byte[] Encode() { var w = new PacketWriter(); w.WriteString(GameId); w.WriteVarInt(Version); + w.WriteVarInt(RequestId); return w.ToArray(); } public static MatchRequestMsg Decode(byte[] data) { var r = new PacketReader(data); - return new MatchRequestMsg { GameId = r.ReadString(), Version = r.ReadVarInt() }; + var m = new MatchRequestMsg { GameId = r.ReadString(), Version = r.ReadVarInt() }; + if (r.HasMore) m.RequestId = r.ReadVarInt(); + return m; } } @@ -67,7 +72,9 @@ namespace XWorld.Framework.Protocol { public string RoomId; // null 编码为空字符串 public int Version; + public string GameId; public int SelfPlayerId; + public int RequestId; public List Players = new List(); public byte[] Encode() @@ -91,6 +98,8 @@ namespace XWorld.Framework.Protocol w.WriteVarInt(p.AppearanceType); w.WriteVarInt(p.AvatarId); } + w.WriteVarInt(RequestId); + w.WriteString(GameId); return w.ToArray(); } @@ -131,6 +140,14 @@ namespace XWorld.Framework.Protocol } } } + if (r.HasMore) + { + m.RequestId = r.ReadVarInt(); + } + if (r.HasMore) + { + m.GameId = r.ReadString(); + } return m; } } @@ -144,6 +161,24 @@ namespace XWorld.Framework.Protocol public int MaxPlayers; } + public sealed class MatchControlMsg + { + public int RequestId; + + public byte[] Encode() + { + var w = new PacketWriter(); + w.WriteVarInt(RequestId); + return w.ToArray(); + } + + public static MatchControlMsg Decode(byte[] data) + { + var r = new PacketReader(data); + return r.HasMore ? new MatchControlMsg { RequestId = r.ReadVarInt() } : new MatchControlMsg(); + } + } + public sealed class GameListResponseMsg { public List Games = new List(); @@ -187,19 +222,23 @@ namespace XWorld.Framework.Protocol { public string GameId; public int Version; + public int RequestId; public byte[] Encode() { var w = new PacketWriter(); w.WriteString(GameId); w.WriteVarInt(Version); + w.WriteVarInt(RequestId); return w.ToArray(); } public static MatchAssignedMsg Decode(byte[] data) { var r = new PacketReader(data); - return new MatchAssignedMsg { GameId = r.ReadString(), Version = r.ReadVarInt() }; + var m = new MatchAssignedMsg { GameId = r.ReadString(), Version = r.ReadVarInt() }; + if (r.HasMore) m.RequestId = r.ReadVarInt(); + return m; } } @@ -268,19 +307,23 @@ namespace XWorld.Framework.Protocol { public int Code; public string Message; // null 编码为空字符串 + public int RequestId; public byte[] Encode() { var w = new PacketWriter(); w.WriteVarInt(Code); w.WriteString(Message); + w.WriteVarInt(RequestId); return w.ToArray(); } public static ErrorMsg Decode(byte[] data) { var r = new PacketReader(data); - return new ErrorMsg { Code = r.ReadVarInt(), Message = r.ReadString() }; + var m = new ErrorMsg { Code = r.ReadVarInt(), Message = r.ReadString() }; + if (r.HasMore) m.RequestId = r.ReadVarInt(); + return m; } } diff --git a/Client/Assets/Game/Art/UI/Prefab/UI_MatchWaiting.prefab b/Client/Assets/Game/Art/UI/Prefab/UI_MatchWaiting.prefab index 73c16337..09097520 100644 --- a/Client/Assets/Game/Art/UI/Prefab/UI_MatchWaiting.prefab +++ b/Client/Assets/Game/Art/UI/Prefab/UI_MatchWaiting.prefab @@ -1066,7 +1066,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} - m_AnchoredPosition: {x: 414, y: -333} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 720, y: 500} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &2156710259985382035 @@ -1766,8 +1766,8 @@ RectTransform: - {fileID: 6853786249801907381} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 0, y: 0} m_Pivot: {x: 0.5, y: 0.5} diff --git a/Client/Assets/Script/Editor/XWorldUtil.cs b/Client/Assets/Script/Editor/XWorldUtil.cs index 777d3564..770679ec 100644 --- a/Client/Assets/Script/Editor/XWorldUtil.cs +++ b/Client/Assets/Script/Editor/XWorldUtil.cs @@ -22,9 +22,11 @@ public class XWorldUtil private const string LOCAL_SERVER_EXE = "../../Server/build-gateway-local3/Debug/AIProjectServer.exe"; private const string LOCAL_SERVER_DLL_DIR = "../../Server/build-local-sqlite/vcpkg_installed/x64-windows/debug/bin"; private const string LOCAL_SERVER_LOG_DIR = "../../Server/logs"; + private const string MINI_GAME_GATEWAY_RUNNER_PROJECT = "../../Server/Gateway.Runner/Gateway.Runner.csproj"; private const string MINI_GAME_GATEWAY_RUNNER_DLL = "../../Server/Gateway.Runner/bin/Debug/net10.0/XWorld.Server.Gateway.Runner.dll"; private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame"; private const string MINI_GAME_GATEWAY_PID_FILE = "pc-minigame-gateway.pid"; + private const string MINI_GAME_CDN_RUNNER_PROJECT = "../../Server/Cdn.Runner/Cdn.Runner.csproj"; private const string MINI_GAME_CDN_RUNNER_DLL = "../../Server/Cdn.Runner/bin/Debug/net10.0/XWorld.Server.Cdn.Runner.dll"; private const string MINI_GAME_CDN_ROOT = "../../CDN"; private const string MINI_GAME_CDN_PID_FILE = "pc-cdn.pid"; @@ -37,6 +39,7 @@ public class XWorldUtil private const int LOCAL_INTERNAL_GAME_PORT = 7003; private const int MINI_GAME_GATEWAY_PORT = 5005; private const int MINI_GAME_TICK_MS = 100; + private const int MINI_GAME_RECONNECT_WINDOW_TICKS = 1800; private const int MINI_GAME_MATCH_TIMEOUT_TICKS = 150; private static readonly List LocalServerProcesses = new List(); @@ -323,6 +326,22 @@ public class XWorldUtil { StopExitedLocalServerProcesses(); SetLocalServerPrefs(); + + if (IsVisibleMiniGameGatewayConsoleRunning()) + { + UnityEngine.Debug.Log("PC MiniGame Gateway console is already running; skipping build."); + return; + } + + if (!ShouldBuildMiniGameLocalServer(IsMiniGameGatewayRunnerRunning())) + { + UnityEngine.Debug.Log("PC MiniGame Gateway is already running; skipping build."); + return; + } + + if (!BuildMiniGameLocalServer()) + return; + // 内网 CDN(HTTP 静态文件)独立于网关,先确保起来(端口已开则跳过) StartMiniGameCdnRunnerProcess("XWorld PC CDN"); if (IsVisibleMiniGameGatewayConsoleRunning()) @@ -390,12 +409,26 @@ public class XWorldUtil "OK"); } + [MenuItem("XWorld/Server/Build Local Server", false, 605)] + static public void BuildLocalServer() + { + if (BuildMiniGameLocalServer()) + EditorUtility.DisplayDialog("XWorld Server", "Local server build finished.", "OK"); + } + + [MenuItem("XWorld/Server/Build CDN Runner", false, 606)] + static public void BuildCdnRunner() + { + if (BuildMiniGameRunnerProject("XWorld PC CDN", GetMiniGameCdnRunnerProjectPath(), GetMiniGameCdnRunnerPath())) + EditorUtility.DisplayDialog("XWorld Server", "CDN runner build finished.", "OK"); + } + static public bool IsPcMiniGameGatewayReady() { return IsMiniGameGatewayRunnerRunning(); } - [MenuItem("XWorld/Server/Open Latest Log", false, 605)] + [MenuItem("XWorld/Server/Open Latest Log", false, 607)] static public void OpenLatestLocalServerLog() { string logDir = Path.GetFullPath(Path.Combine(Application.dataPath, LOCAL_SERVER_LOG_DIR)); @@ -422,7 +455,7 @@ public class XWorldUtil [MenuItem("XWorld/Server/Open Local Server (Separate)", true)] static public bool CanOpenLocalServer() { - return File.Exists(GetMiniGameGatewayRunnerPath()); + return File.Exists(GetMiniGameGatewayRunnerProjectPath()); } [MenuItem("XWorld/Server/Stop Local Server", true)] @@ -484,6 +517,11 @@ public class XWorldUtil return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_GATEWAY_RUNNER_DLL)); } + static private string GetMiniGameGatewayRunnerProjectPath() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_GATEWAY_RUNNER_PROJECT)); + } + static private string GetMiniGameServerGamesRoot() { return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_SERVER_GAMES_ROOT)); @@ -501,6 +539,11 @@ public class XWorldUtil return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_RUNNER_DLL)); } + static private string GetMiniGameCdnRunnerProjectPath() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_RUNNER_PROJECT)); + } + static private string GetMiniGameCdnRoot() { return Path.GetFullPath(Path.Combine(Application.dataPath, MINI_GAME_CDN_ROOT)); @@ -523,6 +566,101 @@ public class XWorldUtil return Path.Combine(logDir, safeTitle + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmss") + ".log"); } + static private bool BuildMiniGameLocalServer() + { + if (!BuildMiniGameRunnerProject("XWorld PC minigame gateway", GetMiniGameGatewayRunnerProjectPath(), GetMiniGameGatewayRunnerPath())) + return false; + + return true; + } + + static private bool ShouldBuildMiniGameLocalServer(bool isGatewayRunning) + { + return !isGatewayRunning; + } + + static private bool BuildMiniGameRunnerProject(string title, string projectPath, string runnerPath) + { + if (!File.Exists(projectPath)) + { + EditorUtility.DisplayDialog("XWorld Server", "Server project not found:\n" + projectPath, "OK"); + return false; + } + + string logPath = GetLocalServerLogPath(title + " build"); + UnityEngine.Debug.Log("Building " + title + " from " + projectPath + "\nLog: " + logPath); + EditorUtility.DisplayProgressBar("XWorld Server", "Building " + title, 0.5f); + + try + { + StringBuilder output = new StringBuilder(); + ProcessStartInfo startInfo = new ProcessStartInfo(); + startInfo.FileName = "dotnet"; + startInfo.Arguments = "build " + QuoteProcessArg(projectPath) + " -c Debug --no-incremental -m:1"; + startInfo.WorkingDirectory = GetProjectRoot(); + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.CreateNoWindow = true; + + using (Process process = new Process()) + { + process.StartInfo = startInfo; + process.OutputDataReceived += (sender, args) => + { + if (args.Data != null) + output.AppendLine(args.Data); + }; + process.ErrorDataReceived += (sender, args) => + { + if (args.Data != null) + output.AppendLine(args.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + process.WaitForExit(1000); + + File.WriteAllText(logPath, output.ToString(), new UTF8Encoding(false)); + if (process.ExitCode != 0) + { + EditorUtility.DisplayDialog("XWorld Server", + title + " build failed. Old DLL will not be started.\n\nLog:\n" + logPath, + "OK"); + UnityEngine.Debug.LogError(title + " build failed with exit code " + process.ExitCode + ". Log: " + logPath); + return false; + } + } + } + catch (Exception ex) + { + File.WriteAllText(logPath, ex.ToString(), new UTF8Encoding(false)); + EditorUtility.DisplayDialog("XWorld Server", + title + " build failed before launch. Old DLL will not be started.\n\n" + ex.Message + "\n\nLog:\n" + logPath, + "OK"); + UnityEngine.Debug.LogError(title + " build failed: " + ex); + return false; + } + finally + { + EditorUtility.ClearProgressBar(); + } + + if (!IsDotnetRunnerLaunchable(runnerPath)) + { + EditorUtility.DisplayDialog("XWorld Server", + title + " build finished, but runner output is incomplete:\n" + runnerPath + + "\n" + GetDotnetRunnerRuntimeConfigPath(runnerPath), + "OK"); + return false; + } + + UnityEngine.Debug.Log(title + " build finished. Runner: " + runnerPath); + return true; + } + static private void StartServerProcess(string arguments, string title) { string exePath = GetLocalServerExePath(); @@ -573,10 +711,12 @@ public class XWorldUtil static private void StartMiniGameGatewayRunnerProcess(string title) { string runnerPath = GetMiniGameGatewayRunnerPath(); - if (!File.Exists(runnerPath)) + if (!IsDotnetRunnerLaunchable(runnerPath)) { EditorUtility.DisplayDialog("XWorld Server", - "Gateway runner not found:\n" + runnerPath + "\n\nBuild Server/Gateway.Runner first.", "OK"); + "Gateway runner output is incomplete:\n" + runnerPath + + "\n" + GetDotnetRunnerRuntimeConfigPath(runnerPath) + + "\n\nBuild Server/Gateway.Runner first.", "OK"); return; } @@ -612,6 +752,7 @@ public class XWorldUtil "Write-Host ''\r\n" + "& dotnet $runner --gamesRoot $gamesRoot --port " + MINI_GAME_GATEWAY_PORT + " --tickMs " + MINI_GAME_TICK_MS + + " --reconnectWindowTicks " + MINI_GAME_RECONNECT_WINDOW_TICKS + " --matchTimeoutTicks " + MINI_GAME_MATCH_TIMEOUT_TICKS + " --lan --devToken " + DEV_DISCOVERY_TOKEN + // 开 DevDiscovery(UDP48923) 响应 + 广播 LAN IP;CDN 地址由 --lan 兜底为 http://:15081/ " *>&1 | Tee-Object -FilePath $logPath\r\n" + @@ -645,9 +786,10 @@ public class XWorldUtil static private void StartMiniGameCdnRunnerProcess(string title) { string runnerPath = GetMiniGameCdnRunnerPath(); - if (!File.Exists(runnerPath)) + if (!IsDotnetRunnerLaunchable(runnerPath)) { - UnityEngine.Debug.LogWarning("[CDN] 未找到 Cdn.Runner:" + runnerPath + "\n请先构建 Server/Cdn.Runner(dotnet build Server/Server.sln)。"); + UnityEngine.Debug.LogWarning("[CDN] Cdn.Runner output is incomplete; skipping CDN startup.\nDLL: " + + runnerPath + "\nRuntimeConfig: " + GetDotnetRunnerRuntimeConfigPath(runnerPath)); return; } @@ -722,6 +864,18 @@ public class XWorldUtil return (text ?? string.Empty).Replace("'", "''"); } + static private string GetDotnetRunnerRuntimeConfigPath(string runnerPath) + { + string directory = Path.GetDirectoryName(runnerPath); + string fileName = Path.GetFileNameWithoutExtension(runnerPath) + ".runtimeconfig.json"; + return Path.Combine(directory ?? string.Empty, fileName); + } + + static private bool IsDotnetRunnerLaunchable(string runnerPath) + { + return File.Exists(runnerPath) && File.Exists(GetDotnetRunnerRuntimeConfigPath(runnerPath)); + } + static private void StopExitedLocalServerProcesses() { LocalServerProcesses.RemoveAll(process => diff --git a/Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs b/Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs new file mode 100644 index 00000000..94dd08db --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +public sealed class MatchWaitingPrefabLayoutTests +{ + private const string PrefabPath = "Assets/Game/Art/UI/Prefab/UI_MatchWaiting.prefab"; + + [Test] + public void WaitingPanel_IsCenteredInSafeArea() + { + GameObject prefab = AssetDatabase.LoadAssetAtPath(PrefabPath); + Assert.That(prefab, Is.Not.Null); + + RectTransform root = prefab.GetComponent(); + Assert.That(root.anchorMin, Is.EqualTo(Vector2.zero)); + Assert.That(root.anchorMax, Is.EqualTo(Vector2.one)); + Assert.That(root.anchoredPosition, Is.EqualTo(Vector2.zero)); + Assert.That(root.sizeDelta, Is.EqualTo(Vector2.zero)); + + Transform panel = prefab.transform.Find("SafeArea/Panel_Waiting"); + Assert.That(panel, Is.Not.Null); + + RectTransform rect = panel.GetComponent(); + Assert.That(rect.anchorMin, Is.EqualTo(new Vector2(0.5f, 0.5f))); + Assert.That(rect.anchorMax, Is.EqualTo(new Vector2(0.5f, 0.5f))); + Assert.That(rect.pivot, Is.EqualTo(new Vector2(0.5f, 0.5f))); + Assert.That(rect.anchoredPosition, Is.EqualTo(Vector2.zero)); + } +} diff --git a/Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs.meta b/Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs.meta new file mode 100644 index 00000000..776b59e2 --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/MatchWaitingPrefabLayoutTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b43c5afbe3e421488be033e25c0c28a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs b/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs index d3d68eb6..1f9c1b52 100644 --- a/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs +++ b/Client/Assets/Script/Tests/EditMode/MiniGameStageIsolationTests.cs @@ -1,5 +1,7 @@ +using System.Reflection; using NUnit.Framework; using UnityEngine; +using XGame; using XGame.MiniGame; public sealed class MiniGameStageIsolationTests @@ -96,6 +98,51 @@ public sealed class MiniGameStageIsolationTests Assert.IsTrue(behaviour.enabled); } + [Test] + public void SuspendAndResume_AutoCapturesCharacterSwitchUiRoot() + { + _root = new GameObject("root"); + GameObject characterObject = new GameObject("character-switch"); + characterObject.transform.SetParent(_root.transform); + GameObject characterUi = new GameObject("UI_CharacterSwitch"); + characterUi.transform.SetParent(_root.transform); + var controller = characterObject.AddComponent(); + typeof(CharacterSwitchController) + .GetField("uiPanel", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(controller, characterUi); + Assert.AreSame(characterUi, controller.StageIsolationUiRoot); + var isolation = _root.AddComponent(); + + isolation.SuspendLobbyForMiniGame(); + + Assert.IsFalse(characterUi.activeSelf); + + isolation.ResumeLobbyAfterMiniGame(); + + Assert.IsTrue(characterUi.activeSelf); + } + + [Test] + public void CharacterSwitchUiLoadedWhileSuspended_IsHidden() + { + _root = new GameObject("root"); + var isolation = _root.AddComponent(); + isolation.SuspendLobbyForMiniGame(); + + GameObject characterObject = new GameObject("character-switch"); + characterObject.transform.SetParent(_root.transform); + GameObject characterUi = new GameObject("UI_CharacterSwitch"); + characterUi.transform.SetParent(_root.transform); + var controller = characterObject.AddComponent(); + typeof(CharacterSwitchController) + .GetField("uiPanel", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(controller, characterUi); + + controller.ApplyStageIsolationState(); + + Assert.IsFalse(characterUi.activeSelf); + } + [Test] public void SuspendAndResume_IgnoreNullEntries() { diff --git a/Client/Assets/Script/Tests/EditMode/XWorldJsonPrefabAutoConvertTests.cs b/Client/Assets/Script/Tests/EditMode/XWorldJsonPrefabAutoConvertTests.cs index 0e2a4728..554e403f 100644 --- a/Client/Assets/Script/Tests/EditMode/XWorldJsonPrefabAutoConvertTests.cs +++ b/Client/Assets/Script/Tests/EditMode/XWorldJsonPrefabAutoConvertTests.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Reflection; using NUnit.Framework; using UnityEditor; @@ -33,3 +34,84 @@ public sealed class XWorldJsonPrefabAutoConvertTests return (bool)method.Invoke(null, null); } } + +public sealed class XWorldLocalServerBuildTests +{ + [Test] + public void LocalServerMenuUsesGatewayProjectInsteadOfPrebuiltDll() + { + string gatewayProjectPath = InvokeStringPath("GetMiniGameGatewayRunnerProjectPath"); + + Assert.That(gatewayProjectPath.Replace('\\', '/'), Does.EndWith("/Server/Gateway.Runner/Gateway.Runner.csproj")); + Assert.That(File.Exists(gatewayProjectPath), Is.True); + Assert.That(XWorldUtil.CanOpenLocalServer(), Is.True); + } + + [Test] + public void CdnRunnerBuildMenuUsesCdnProject() + { + string cdnProjectPath = InvokeStringPath("GetMiniGameCdnRunnerProjectPath"); + + Assert.That(cdnProjectPath.Replace('\\', '/'), Does.EndWith("/Server/Cdn.Runner/Cdn.Runner.csproj")); + Assert.That(File.Exists(cdnProjectPath), Is.True); + } + + [Test] + public void LocalServerBuildIsSkippedWhenGatewayIsAlreadyRunning() + { + Assert.That(InvokeShouldBuildLocalServer(false), Is.True); + Assert.That(InvokeShouldBuildLocalServer(true), Is.False); + } + + [Test] + public void DotnetRunnerLaunchableRequiresRuntimeConfigNextToDll() + { + string tempDir = Path.Combine(Path.GetTempPath(), "XWorldRunnerTest-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + string runnerPath = Path.Combine(tempDir, "Example.Runner.dll"); + File.WriteAllText(runnerPath, string.Empty); + + Assert.That(InvokeLaunchable(runnerPath), Is.False); + + File.WriteAllText(Path.Combine(tempDir, "Example.Runner.runtimeconfig.json"), "{}"); + + Assert.That(InvokeLaunchable(runnerPath), Is.True); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + private static string InvokeStringPath(string methodName) + { + MethodInfo method = typeof(XWorldUtil).GetMethod( + methodName, + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null); + return (string)method.Invoke(null, null); + } + + private static bool InvokeLaunchable(string runnerPath) + { + MethodInfo method = typeof(XWorldUtil).GetMethod( + "IsDotnetRunnerLaunchable", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null); + return (bool)method.Invoke(null, new object[] { runnerPath }); + } + + private static bool InvokeShouldBuildLocalServer(bool isGatewayRunning) + { + MethodInfo method = typeof(XWorldUtil).GetMethod( + "ShouldBuildMiniGameLocalServer", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null); + return (bool)method.Invoke(null, new object[] { isGatewayRunning }); + } +} diff --git a/Client/Assets/Script/xmain/Character/CharacterSwitchController.cs b/Client/Assets/Script/xmain/Character/CharacterSwitchController.cs index 51b91c17..c8ef94b3 100644 --- a/Client/Assets/Script/xmain/Character/CharacterSwitchController.cs +++ b/Client/Assets/Script/xmain/Character/CharacterSwitchController.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; +using XGame.MiniGame; using UObject = UnityEngine.Object; namespace XGame @@ -35,6 +36,11 @@ namespace XGame get { return currentConfig == null ? string.Empty : currentConfig.id; } } + public GameObject StageIsolationUiRoot + { + get { return uiPanel; } + } + private void Start() { LoadConfig(); @@ -178,6 +184,15 @@ namespace XGame CacheUI(); BuildCharacterButtons(); + ApplyStageIsolationState(); + } + + public void ApplyStageIsolationState() + { + if (uiPanel != null && MiniGameStageIsolation.HasSuspendedLobby()) + { + uiPanel.SetActive(false); + } } private void CacheUI() diff --git a/Client/Assets/Script/xmain/Client/CSharpClientApp.cs b/Client/Assets/Script/xmain/Client/CSharpClientApp.cs index 253f24d3..67a78538 100644 --- a/Client/Assets/Script/xmain/Client/CSharpClientApp.cs +++ b/Client/Assets/Script/xmain/Client/CSharpClientApp.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.IO; using UnityEngine; using XGame.MiniGame; +using XWorld.Framework; using XWorld.Framework.Protocol; using UObject = UnityEngine.Object; @@ -37,13 +38,19 @@ namespace XGame private XWebSocket socket; private MiniGameNetChannel lobbyNet; private MiniGameHost gameHost; + private MiniGameStageIsolation stageIsolation; private LobbyWorldController lobbyWorld; private WorldChatModule worldChat; private readonly List games = new List(); + private readonly List pendingGameMessages = new List(); + private readonly HashSet canceledMatchRequestIds = new HashSet(); private bool lobbyActive; private bool openingLogin; private bool openingLobby; private bool openingWaiting; + private bool matchRequestActive; + private int nextMatchRequestId; + private int currentMatchRequestId; private string matchMessage = string.Empty; private string pendingGameId = string.Empty; private int pendingGameVersion; @@ -409,6 +416,7 @@ namespace XGame socket = sock; lobbyNet = new MiniGameNetChannel(sock); + lobbyNet.OnGameMessage = OnLobbyGameMessage; lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame; lobbyActive = true; SetLobbyStatus("Lobby connected."); @@ -455,6 +463,9 @@ namespace XGame private void OnMatchClicked(string gameId) { + matchRequestActive = true; + currentMatchRequestId = ++nextMatchRequestId; + canceledMatchRequestIds.Remove(currentMatchRequestId); matchMessage = "Matching..."; pendingGameId = string.Empty; pendingGameVersion = 0; @@ -468,7 +479,7 @@ namespace XGame if (string.IsNullOrEmpty(gameId)) { - lobbyNet.SendFramework(FrameworkOpcode.RandomMatchRequest, Array.Empty()); + lobbyNet.SendFramework(FrameworkOpcode.RandomMatchRequest, new MatchControlMsg { RequestId = currentMatchRequestId }.Encode()); } else { @@ -483,7 +494,7 @@ namespace XGame } pendingGameId = gameId; pendingGameVersion = version; - lobbyNet.SendFramework(FrameworkOpcode.MatchRequest, new MatchRequestMsg { GameId = gameId, Version = version }.Encode()); + lobbyNet.SendFramework(FrameworkOpcode.MatchRequest, new MatchRequestMsg { GameId = gameId, Version = version, RequestId = currentMatchRequestId }.Encode()); } } @@ -530,6 +541,8 @@ namespace XGame private void OnCloseWaiting() { + CancelCurrentMatchRequest(); + matchRequestActive = false; matchMessage = string.Empty; pendingGameId = string.Empty; pendingGameVersion = 0; @@ -537,6 +550,34 @@ namespace XGame OpenLobby(); } + private void CancelPendingMatch() + => CancelPendingMatch(currentMatchRequestId); + + private void CancelCurrentMatchRequest() + { + if (currentMatchRequestId != 0) + { + canceledMatchRequestIds.Add(currentMatchRequestId); + } + CancelPendingMatch(currentMatchRequestId); + } + + private void CancelPendingMatch(int requestId) + { + if (lobbyNet == null || !lobbyNet.IsConnected) + { + return; + } + + lobbyNet.SendFramework(FrameworkOpcode.MatchCancel, new MatchControlMsg { RequestId = requestId }.Encode()); + } + + private bool IsCurrentMatchResponse(int requestId) + => matchRequestActive && (requestId == 0 || requestId == currentMatchRequestId); + + private bool IsCanceledMatchResponse(int requestId) + => requestId != 0 && canceledMatchRequestIds.Contains(requestId); + private void OnLobbyFrameworkFrame(Frame frame) { switch ((FrameworkOpcode)frame.Opcode) @@ -549,6 +590,16 @@ namespace XGame break; case FrameworkOpcode.MatchAssigned: MatchAssignedMsg assigned = MatchAssignedMsg.Decode(frame.Payload); + if (IsCanceledMatchResponse(assigned.RequestId)) + { + Debug.Log("[CSharpClientApp] ignore canceled match assigned req=" + assigned.RequestId); + break; + } + if (!IsCurrentMatchResponse(assigned.RequestId)) + { + Debug.Log("[CSharpClientApp] ignore stale match assigned req=" + assigned.RequestId); + break; + } pendingGameId = assigned.GameId; pendingGameVersion = assigned.Version; Debug.Log("[CSharpClientApp] match assigned " + pendingGameId + "@" + pendingGameVersion); @@ -556,10 +607,31 @@ namespace XGame break; case FrameworkOpcode.MatchFound: MatchFoundMsg found = MatchFoundMsg.Decode(frame.Payload); + if (IsCanceledMatchResponse(found.RequestId)) + { + Debug.Log("[CSharpClientApp] ignore canceled match found req=" + found.RequestId); + CancelPendingMatch(found.RequestId); + break; + } + if (!matchRequestActive && !string.IsNullOrEmpty(found.GameId)) + { + Debug.Log("[CSharpClientApp] resume game room=" + found.RoomId + " game=" + found.GameId + "@" + found.Version); + EnterAssignedGame(found.GameId, found.Version, found); + break; + } + if (!IsCurrentMatchResponse(found.RequestId)) + { + Debug.Log("[CSharpClientApp] ignore stale match found req=" + found.RequestId); + if (found.RequestId != 0) + { + CancelPendingMatch(found.RequestId); + } + break; + } Debug.Log("[CSharpClientApp] match found room=" + found.RoomId + " self=" + found.SelfPlayerId); if (string.IsNullOrEmpty(pendingGameId)) { - SetWaitingStatus("Room ready, but no game was assigned.", true); + AbortMatchAndReturnLobby("Match expired, please retry.", found.RequestId); break; } SetWaitingStatus("Room ready.", false); @@ -567,6 +639,16 @@ namespace XGame break; case FrameworkOpcode.Error: ErrorMsg err = ErrorMsg.Decode(frame.Payload); + if (IsCanceledMatchResponse(err.RequestId)) + { + Debug.Log("[CSharpClientApp] ignore canceled match error req=" + err.RequestId); + break; + } + if (!IsCurrentMatchResponse(err.RequestId)) + { + Debug.Log("[CSharpClientApp] ignore stale match error req=" + err.RequestId); + break; + } pendingGameId = string.Empty; pendingGameVersion = 0; SetWaitingStatus("Server error " + err.Code + ": " + err.Message, true); @@ -590,11 +672,28 @@ namespace XGame } } + private void OnLobbyGameMessage(NetMessage message) + { + if (gameHost != null) + { + gameHost.QueueInitialMessage(message); + return; + } + + pendingGameMessages.Add(message); + if (pendingGameMessages.Count > 16) + { + pendingGameMessages.RemoveAt(0); + } + } + private void EnterAssignedGame(string gameId, int version, MatchFoundMsg found) { + matchRequestActive = false; pendingGameId = string.Empty; pendingGameVersion = 0; lobbyActive = false; + SuspendLobbyStage(); lobbyWorld?.SendLeave(); CloseWorldChat(); lobbyNet = null; @@ -607,17 +706,73 @@ namespace XGame } gameHost.OnGameExited = () => { + ResumeLobbyStage(); matchMessage = "Returned to lobby."; lobbyNet = new MiniGameNetChannel(socket); + lobbyNet.OnGameMessage = OnLobbyGameMessage; lobbyNet.OnFrameworkFrame = OnLobbyFrameworkFrame; lobbyActive = true; OpenLobby(); JoinLobbyWorld(); RequestGameList(); }; - gameHost.EnterGame(ResolvedCdnBase(), gameId, version, socket, false); // 大厅链路 MatchFound 由大厅通道消费,宿主收不到,须显式注入房间信息(结算弹框判胜负用) - gameHost.SetMatchInfo(found.RoomId, found.SelfPlayerId); + gameHost.SetMatchInfo(found.RoomId, found.SelfPlayerId, found.Players); + gameHost.EnterGame(ResolvedCdnBase(), gameId, version, socket, false); + for (int i = 0; i < pendingGameMessages.Count; i++) + { + gameHost.QueueInitialMessage(pendingGameMessages[i]); + } + pendingGameMessages.Clear(); + } + + private void AbortMatchAndReturnLobby(string message, int requestId) + { + int cancelRequestId = requestId != 0 ? requestId : currentMatchRequestId; + if (cancelRequestId != 0) + { + canceledMatchRequestIds.Add(cancelRequestId); + } + CancelPendingMatch(cancelRequestId); + matchRequestActive = false; + pendingGameId = string.Empty; + pendingGameVersion = 0; + CloseWaiting(); + OpenLobby(); + SetLobbyStatus(message); + } + + private MiniGameStageIsolation ResolveStageIsolation() + { + if (stageIsolation != null) + { + return stageIsolation; + } + + stageIsolation = GetComponent(); + if (stageIsolation == null) + { + stageIsolation = gameObject.AddComponent(); + } + return stageIsolation; + } + + private void SuspendLobbyStage() + { + MiniGameStageIsolation isolation = ResolveStageIsolation(); + if (isolation != null) + { + isolation.SuspendLobbyForMiniGame(); + } + } + + private void ResumeLobbyStage() + { + MiniGameStageIsolation isolation = ResolveStageIsolation(); + if (isolation != null) + { + isolation.ResumeLobbyAfterMiniGame(); + } } private string ResolvedCdnBase() diff --git a/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs b/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs index dfd15bf9..ab9f8938 100644 --- a/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs +++ b/Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs @@ -22,6 +22,7 @@ namespace XGame.MiniGame private GameClientCtx _ctx; private MiniGameAssetLoader _assets; private RoomPlayerService _players; + private readonly List _queuedInitialMessages = new List(); private bool _running; private bool _waitingForResultConfirm; private string _gameId; @@ -38,10 +39,22 @@ namespace XGame.MiniGame public void EnterGame(string cdnBase, string gameId, int version, XWebSocket sock, bool sendMatchRequest) { _gameId = gameId; _version = version; + _queuedInitialMessages.Clear(); // FIX: XWCoroutine.StartCo is a static method (not Instance.StartCo) XWCoroutine.StartCo(CoEnter(cdnBase, gameId, version, sock, sendMatchRequest)); } + public void QueueInitialMessage(NetMessage message) + { + if (_client != null) + { + SafeCall(() => _client.OnNetMessage(message), "OnNetMessage"); + return; + } + + _queuedInitialMessages.Add(message); + } + // 大厅链路:MatchFound 由大厅通道消费(sendMatchRequest=false 时宿主收不到), // 由外部把房间信息注入,否则结算弹框无法判断胜负(_selfPlayerId 恒 0)。 public void SetMatchInfo(string roomId, int selfPlayerId) @@ -122,6 +135,7 @@ namespace XGame.MiniGame try { _client.OnEnter(_ctx); + FlushQueuedInitialMessages(); } catch (Exception e) { @@ -165,6 +179,7 @@ namespace XGame.MiniGame public void ExitGame() { if (!_running && _client == null) return; + _queuedInitialMessages.Clear(); DestroyResultDialog(); _waitingForResultConfirm = false; _running = false; @@ -183,6 +198,7 @@ namespace XGame.MiniGame private void FailEnterAndExit(string reason) { + _queuedInitialMessages.Clear(); DestroyResultDialog(); _waitingForResultConfirm = false; _running = false; @@ -443,6 +459,18 @@ namespace XGame.MiniGame return button; } + private void FlushQueuedInitialMessages() + { + if (_client == null || _queuedInitialMessages.Count == 0) return; + NetMessage[] messages = _queuedInitialMessages.ToArray(); + _queuedInitialMessages.Clear(); + for (int i = 0; i < messages.Length; i++) + { + NetMessage message = messages[i]; + SafeCall(() => _client.OnNetMessage(message), "OnNetMessage"); + } + } + private static void SafeCall(Action a, string where) { try { a(); } diff --git a/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs b/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs index 89b0fad9..ae0dd387 100644 --- a/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs +++ b/Client/Assets/Script/xmain/MiniGame/MiniGameStageIsolation.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using UnityEngine; +using XGame; namespace XGame.MiniGame { @@ -17,6 +18,19 @@ namespace XGame.MiniGame public bool IsSuspended { get; private set; } + public static bool HasSuspendedLobby() + { + MiniGameStageIsolation[] isolations = FindObjectsOfType(true); + for (int i = 0; i < isolations.Length; i++) + { + if (isolations[i] != null && isolations[i].IsSuspended) + { + return true; + } + } + return false; + } + public void SuspendLobbyForMiniGame() { if (IsSuspended) @@ -77,6 +91,7 @@ namespace XGame.MiniGame _behaviourStates.Clear(); _canvasGroupStates.Clear(); CaptureGameObjects(LobbyUiRoots); + CaptureCharacterSwitchUiRoots(); CaptureGameObjects(LobbySceneRoots); CaptureBehaviours(LobbyInputBehaviours); CaptureBehaviours(LobbyTickBehaviours); @@ -86,6 +101,7 @@ namespace XGame.MiniGame private void ApplySuspendedState() { SetGameObjectsActive(LobbyUiRoots, false); + SetCharacterSwitchUiRootsActive(false); SetGameObjectsActive(LobbySceneRoots, false); SetBehavioursEnabled(LobbyInputBehaviours, false); SetBehavioursEnabled(LobbyTickBehaviours, false); @@ -105,6 +121,19 @@ namespace XGame.MiniGame } } + private void CaptureCharacterSwitchUiRoots() + { + CharacterSwitchController[] controllers = FindObjectsOfType(true); + for (int i = 0; i < controllers.Length; i++) + { + GameObject target = controllers[i] != null ? controllers[i].StageIsolationUiRoot : null; + if (target != null) + { + _gameObjectStates.Add(new GameObjectState(target, target.activeSelf)); + } + } + } + private void CaptureBehaviours(Behaviour[] targets) { if (targets == null) return; @@ -143,6 +172,19 @@ namespace XGame.MiniGame } } + private static void SetCharacterSwitchUiRootsActive(bool active) + { + CharacterSwitchController[] controllers = FindObjectsOfType(true); + for (int i = 0; i < controllers.Length; i++) + { + GameObject target = controllers[i] != null ? controllers[i].StageIsolationUiRoot : null; + if (target != null) + { + target.SetActive(active); + } + } + } + private static void SetBehavioursEnabled(Behaviour[] targets, bool enabled) { if (targets == null) return; diff --git a/Server/Framework.Shared.Tests/FrameworkMessagesTests.cs b/Server/Framework.Shared.Tests/FrameworkMessagesTests.cs index 3587bdbf..b781fbd4 100644 --- a/Server/Framework.Shared.Tests/FrameworkMessagesTests.cs +++ b/Server/Framework.Shared.Tests/FrameworkMessagesTests.cs @@ -18,10 +18,11 @@ namespace XWorld.Framework.Tests [Fact] public void MatchRequest_RoundTrips() { - var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3 }; + var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3, RequestId = 42 }; var d = MatchRequestMsg.Decode(m.Encode()); Assert.Equal(m.GameId, d.GameId); Assert.Equal(m.Version, d.Version); + Assert.Equal(m.RequestId, d.RequestId); } [Fact] @@ -29,9 +30,11 @@ namespace XWorld.Framework.Tests { var m = new MatchFoundMsg { + GameId = "rps", RoomId = "room-42", Version = 3, SelfPlayerId = 1001, + RequestId = 99, Players = { new PlayerInfo @@ -55,9 +58,11 @@ namespace XWorld.Framework.Tests }, }; var d = MatchFoundMsg.Decode(m.Encode()); + Assert.Equal("rps", d.GameId); Assert.Equal("room-42", d.RoomId); Assert.Equal(3, d.Version); Assert.Equal(1001, d.SelfPlayerId); + Assert.Equal(99, d.RequestId); Assert.Equal(2, d.Players.Count); Assert.Equal(1001, d.Players[0].PlayerId); Assert.Equal("Alice", d.Players[0].Name); @@ -101,10 +106,30 @@ namespace XWorld.Framework.Tests [Fact] public void Error_RoundTrips() { - var m = new ErrorMsg { Code = 404, Message = "version mismatch" }; + var m = new ErrorMsg { Code = 404, Message = "version mismatch", RequestId = 7 }; var d = ErrorMsg.Decode(m.Encode()); Assert.Equal(404, d.Code); Assert.Equal("version mismatch", d.Message); + Assert.Equal(7, d.RequestId); + } + + [Fact] + public void MatchControl_RoundTrips() + { + var m = new MatchControlMsg { RequestId = 123 }; + var d = MatchControlMsg.Decode(m.Encode()); + Assert.Equal(123, d.RequestId); + Assert.Equal(0, MatchControlMsg.Decode(System.Array.Empty()).RequestId); + } + + [Fact] + public void MatchAssigned_RoundTrips() + { + var m = new MatchAssignedMsg { GameId = "rps", Version = 2, RequestId = 88 }; + var d = MatchAssignedMsg.Decode(m.Encode()); + Assert.Equal("rps", d.GameId); + Assert.Equal(2, d.Version); + Assert.Equal(88, d.RequestId); } [Fact] @@ -141,6 +166,7 @@ namespace XWorld.Framework.Tests Assert.Equal((ushort)7, (ushort)FrameworkOpcode.Error); Assert.Equal((ushort)16, (ushort)FrameworkOpcode.WorldChatSend); Assert.Equal((ushort)17, (ushort)FrameworkOpcode.WorldChatMessage); + Assert.Equal((ushort)18, (ushort)FrameworkOpcode.MatchCancel); } [Fact] @@ -209,6 +235,7 @@ namespace XWorld.Framework.Tests MatchFoundMsg d = MatchFoundMsg.Decode(w.ToArray()); Assert.Equal("legacy-room", d.RoomId); + Assert.Equal(0, d.RequestId); Assert.Equal(2, d.Players.Count); Assert.Equal(1001, d.Players[0].PlayerId); Assert.Equal("LegacyAlice", d.Players[0].Name); diff --git a/Server/Gateway.Runner/Program.cs b/Server/Gateway.Runner/Program.cs index 5c19d02d..681ca390 100644 --- a/Server/Gateway.Runner/Program.cs +++ b/Server/Gateway.Runner/Program.cs @@ -14,6 +14,9 @@ namespace XWorld.Server.Gateway.Runner Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Server.Host.Tests", "TestGames")); int port = int.TryParse(GetArg(args, "--port"), out int p) ? p : 5005; int tickMs = int.TryParse(GetArg(args, "--tickMs"), out int t) ? t : 100; + long reconnectWindowTicks = long.TryParse(GetArg(args, "--reconnectWindowTicks"), out long rw) + ? rw + : ServerLoop.DefaultReconnectWindowTicks; long matchTimeoutTicks = long.TryParse(GetArg(args, "--matchTimeoutTicks"), out long mt) ? mt : 150; string devToken = GetArg(args, "--devToken"); string advertiseWs = GetArg(args, "--advertiseWs"); @@ -49,13 +52,15 @@ namespace XWorld.Server.Gateway.Runner } var host = new GameServerHost(); - await host.StartAsync(gamesRoot, tickMs, matchTimeoutTicks: matchTimeoutTicks, listenPort: port, + await host.StartAsync(gamesRoot, tickMs, reconnectWindowTicks: reconnectWindowTicks, + matchTimeoutTicks: matchTimeoutTicks, listenPort: port, bindAllInterfaces: lan, devDiscoveryToken: devToken, advertiseGatewayUrl: advertiseWs, advertiseResourceBaseUrl: advertiseCdn, authSecret: authSecret, authDbPath: authDbPath); Console.WriteLine("XWorld Gateway smoke server started."); Console.WriteLine(" gamesRoot: " + gamesRoot); - Console.WriteLine(" tickMs: " + tickMs + ", matchTimeoutTicks: " + matchTimeoutTicks); + Console.WriteLine(" tickMs: " + tickMs + ", reconnectWindowTicks: " + reconnectWindowTicks + + ", matchTimeoutTicks: " + matchTimeoutTicks); Console.WriteLine(" ws: " + host.WsBaseUrl + "/ws?pid=1"); if (!string.IsNullOrEmpty(devToken)) Console.WriteLine(" dev-discovery: ON (UDP " + host.DevDiscoveryPort + "), advertise ws=" + diff --git a/Server/Gateway.Tests/ServerLoopMatchmakingTests.cs b/Server/Gateway.Tests/ServerLoopMatchmakingTests.cs index 38d5a958..34aca846 100644 --- a/Server/Gateway.Tests/ServerLoopMatchmakingTests.cs +++ b/Server/Gateway.Tests/ServerLoopMatchmakingTests.cs @@ -192,6 +192,67 @@ namespace XWorld.Server.Gateway.Tests Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error); } + [Fact] + public void MatchCancel_AfterRoomCreated_ClearsRoomAndAllowsPlayerToMatchAgain() + { + var loop = NewLoop(matchTimeoutTicks: 0); + var c = new FakeConnection(5); + loop.Submit(new ConnectCommand { PlayerId = 5, Connection = c }); + loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.RandomMatchRequest) }); + loop.DrainAndTick(1f); + + Assert.True(HasMatchFound(c), "precondition: player should be in a room before cancel"); + + c.Sent.Clear(); + loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.MatchCancel) }); + loop.DrainAndTick(1f); + + loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameworkFrame(FrameworkOpcode.RandomMatchRequest) }); + loop.DrainAndTick(1f); + + Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchAssigned); + Assert.DoesNotContain(Frames(c), f => + f.Channel == Channel.Framework && + f.Opcode == (ushort)FrameworkOpcode.Error && + ErrorMsg.Decode(f.Payload).Message == "already in room or queue"); + } + + [Fact] + public void MatchCancel_InTwoPlayerRoom_ClearsRoomForAllPlayers() + { + var loop = NewLoop(matchTimeoutTicks: 100); + var c1 = new FakeConnection(1); + var c2 = new FakeConnection(2); + loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 }); + loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 }); + loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) }); + loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) }); + loop.DrainAndTick(1f); + + Assert.True(HasMatchFound(c1), "precondition: player 1 should be in a room before cancel"); + Assert.True(HasMatchFound(c2), "precondition: player 2 should be in a room before cancel"); + + c1.Sent.Clear(); + c2.Sent.Clear(); + loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameworkFrame(FrameworkOpcode.MatchCancel) }); + loop.DrainAndTick(1f); + + loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) }); + loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) }); + loop.DrainAndTick(1f); + + Assert.True(HasMatchFound(c1), "player 1 should be able to match again after cancel"); + Assert.True(HasMatchFound(c2), "player 2 should be able to match again after the room was cancelled"); + Assert.DoesNotContain(Frames(c1), f => + f.Channel == Channel.Framework && + f.Opcode == (ushort)FrameworkOpcode.Error && + ErrorMsg.Decode(f.Payload).Message == "already in room or queue"); + Assert.DoesNotContain(Frames(c2), f => + f.Channel == Channel.Framework && + f.Opcode == (ushort)FrameworkOpcode.Error && + ErrorMsg.Decode(f.Payload).Message == "already in room or queue"); + } + /// /// Player 1 disconnects while still queued (before match forms). /// DrainAndTick processes the DisconnectCommand (which dequeues pid=1) BEFORE Poll, diff --git a/Server/Gateway.Tests/ServerLoopTests.cs b/Server/Gateway.Tests/ServerLoopTests.cs index 7097453d..217dee60 100644 --- a/Server/Gateway.Tests/ServerLoopTests.cs +++ b/Server/Gateway.Tests/ServerLoopTests.cs @@ -140,6 +140,42 @@ namespace XWorld.Server.Gateway.Tests Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game); } + [Fact] + public void Reconnect_ReplaysMatchFoundAndLatestGameSnapshot() + { + var loop = NewLoop(reconnectWindow: 100); + var c1 = new FakeConnection(1); + var c2 = new FakeConnection(2); + loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 }); + loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 }); + loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) }); + loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) }); + loop.DrainAndTick(1f); + + loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 }); + loop.DrainAndTick(1f); + + var c1b = new FakeConnection(1); + loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b }); + loop.DrainAndTick(0f); + + Frame resumeFrame = Frames(c1b).Find(f => + f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound); + Assert.NotEqual(default, resumeFrame); + MatchFoundMsg resume = MatchFoundMsg.Decode(resumeFrame.Payload); + Assert.Equal("rps", resume.GameId); + Assert.Equal(1, resume.Version); + Assert.Equal(1, resume.SelfPlayerId); + Assert.Equal(2, resume.Players.Count); + Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game); + } + + [Fact] + public void DefaultReconnectWindow_IsThreeMinutesAtDefaultTickRate() + { + Assert.Equal(1800, ServerLoop.DefaultReconnectWindowTicks); + } + [Fact] public void ExpiredSession_IsSwept() { @@ -173,6 +209,27 @@ namespace XWorld.Server.Gateway.Tests Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error); } + [Fact] + public void MatchCancel_WhileQueued_CancelsQueuedMatchAndAllowsPlayerToMatchAgain() + { + var loop = NewLoop(); + var c = new FakeConnection(1); + loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c }); + loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) }); + loop.DrainAndTick(1f); + + c.Sent.Clear(); + loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchCancel, System.Array.Empty()) }); + loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.RandomMatchRequest, System.Array.Empty()) }); + loop.DrainAndTick(1f); + + Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchAssigned); + Assert.DoesNotContain(Frames(c), f => + f.Channel == Channel.Framework && + f.Opcode == (ushort)FrameworkOpcode.Error && + ErrorMsg.Decode(f.Payload).Message == "already in room or queue"); + } + [Fact] public void GameFrame_WithNoRoom_IsIgnoredSafely() { diff --git a/Server/Gateway/GameServerHost.cs b/Server/Gateway/GameServerHost.cs index f7709d1e..e9d6e7ca 100644 --- a/Server/Gateway/GameServerHost.cs +++ b/Server/Gateway/GameServerHost.cs @@ -31,7 +31,7 @@ namespace XWorld.Server.Gateway public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321 public int DevDiscoveryPort => _discovery?.Port ?? 0; - public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100, + public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = ServerLoop.DefaultReconnectWindowTicks, long matchTimeoutTicks = 150, float? logicalDt = null, string publicKeyPem = null, int listenPort = 0, bool bindAllInterfaces = false, string devDiscoveryToken = null, string advertiseGatewayUrl = null, string advertiseResourceBaseUrl = null, diff --git a/Server/Gateway/Matchmaker.cs b/Server/Gateway/Matchmaker.cs index 2e420a69..6457ead0 100644 --- a/Server/Gateway/Matchmaker.cs +++ b/Server/Gateway/Matchmaker.cs @@ -4,10 +4,10 @@ namespace XWorld.Server.Gateway { public sealed class Matchmaker { - public sealed class Seat { public int PlayerId; public bool IsAi; } + public sealed class Seat { public int PlayerId; public bool IsAi; public int RequestId; } public sealed class Match { public string GameId; public int Version; public List Seats = new List(); } - private sealed class Waiter { public int Pid; public long EnqueuedTick; } + private sealed class Waiter { public int Pid; public int RequestId; public long EnqueuedTick; } private sealed class Bucket { public int PlayerCount; public List Waiters = new List(); } private readonly long _timeoutTicks; @@ -17,11 +17,11 @@ namespace XWorld.Server.Gateway public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; } private static string Key(string g, int v) => $"{g}@{v}"; - public void Enqueue(int pid, string gameId, int version, int playerCount, long tick) + public void Enqueue(int pid, string gameId, int version, int playerCount, long tick, int requestId = 0) { string k = Key(gameId, version); if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; } - b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick }); + b.Waiters.Add(new Waiter { Pid = pid, RequestId = requestId, EnqueuedTick = tick }); } // 从等待队列移除某玩家(断线时调用);返回是否移除 @@ -51,7 +51,7 @@ namespace XWorld.Server.Gateway var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) }; int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count; for (int i = 0; i < take; i++) - m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, IsAi = false }); + m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, RequestId = b.Waiters[i].RequestId, IsAi = false }); b.Waiters.RemoveRange(0, take); while (m.Seats.Count < b.PlayerCount) m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid diff --git a/Server/Gateway/RoomOutputSink.cs b/Server/Gateway/RoomOutputSink.cs index 400c8e8a..64eb8d84 100644 --- a/Server/Gateway/RoomOutputSink.cs +++ b/Server/Gateway/RoomOutputSink.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using XWorld.Framework; using XWorld.Framework.Protocol; @@ -11,14 +12,16 @@ namespace XWorld.Server.Gateway private readonly string _roomId; // 保留作诊断/日志标识 private readonly IReadOnlyList _players; private readonly SessionManager _sessions; + private readonly Action _onBroadcast; - public RoomOutputSink(string roomId, IReadOnlyList players, SessionManager sessions) + public RoomOutputSink(string roomId, IReadOnlyList players, SessionManager sessions, Action onBroadcast = null) { - _roomId = roomId; _players = players; _sessions = sessions; + _roomId = roomId; _players = players; _sessions = sessions; _onBroadcast = onBroadcast; } public void Broadcast(NetMessage message) { + _onBroadcast?.Invoke(message); byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)); foreach (var pid in _players) _sessions.GetConnection(pid)?.Send(frame); diff --git a/Server/Gateway/ServerLoop.cs b/Server/Gateway/ServerLoop.cs index 5092e324..91bec462 100644 --- a/Server/Gateway/ServerLoop.cs +++ b/Server/Gateway/ServerLoop.cs @@ -21,16 +21,31 @@ namespace XWorld.Server.Gateway private readonly Matchmaker _matchmaker; private readonly ChannelsNS.Channel _cmds = ChannelsNS.Channel.CreateUnbounded(); + public const long DefaultReconnectWindowTicks = 1800; private const int MaxWorldChatTextLength = 200; private readonly Dictionary> _roomPlayers = new Dictionary>(); + private readonly Dictionary _activeRooms = new Dictionary(); private readonly Dictionary _lobbyPlayers = new Dictionary(); private readonly Dictionary _playerCountCache = new Dictionary(); // "gameId@version" -> playerCount private readonly HashSet _queuedPids = new HashSet(); // pids currently in matchmaker queue + private readonly Dictionary _queuedRequestIds = new Dictionary(); + private readonly Dictionary _activeRoomRequestIds = new Dictionary(); private readonly Random _random = new Random(1); private long _tick; private int _roomSeq; - public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100, + private sealed class ActiveRoomInfo + { + public string RoomId; + public string GameId; + public int Version; + public List Players; + public Dictionary RequestIds = new Dictionary(); + public bool HasLastGameMessage; + public NetMessage LastGameMessage; + } + + public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = DefaultReconnectWindowTicks, long matchTimeoutTicks = 150, string publicKeyPem = null) { _logger = logger; @@ -104,11 +119,12 @@ namespace XWorld.Server.Gateway case ConnectCommand cc: _sessions.OnConnect(cc.PlayerId, cc.Connection); _logger.Info($"session connect pid={cc.PlayerId}"); + SendRoomResumeIfNeeded(cc.PlayerId); break; case DisconnectCommand dc: _sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick); _logger.Info($"session disconnect pid={dc.PlayerId}"); - if (_queuedPids.Remove(dc.PlayerId)) _matchmaker.Remove(dc.PlayerId); + CancelQueuedMatch(dc.PlayerId); if (_lobbyPlayers.Remove(dc.PlayerId)) BroadcastLobbyState(); break; case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break; @@ -140,13 +156,16 @@ namespace XWorld.Server.Gateway break; case FrameworkOpcode.MatchRequest: var req = MatchRequestMsg.Decode(frame.Payload); - EnqueueMatch(pid, req.GameId, req.Version); + EnqueueMatch(pid, req.GameId, req.Version, req.RequestId); break; case FrameworkOpcode.GameListRequest: SendFramework(pid, FrameworkOpcode.GameListResponse, BuildGameList().Encode()); break; case FrameworkOpcode.RandomMatchRequest: - EnqueueRandomMatch(pid); + EnqueueRandomMatch(pid, MatchControlMsg.Decode(frame.Payload).RequestId); + break; + case FrameworkOpcode.MatchCancel: + CancelMatchState(pid, MatchControlMsg.Decode(frame.Payload).RequestId); break; case FrameworkOpcode.LobbyJoin: HandleLobbyJoin(pid, frame.Payload); @@ -155,6 +174,7 @@ namespace XWorld.Server.Gateway HandleLobbyMove(pid, frame.Payload); break; case FrameworkOpcode.LobbyLeave: + CancelQueuedMatch(pid); if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState(); break; case FrameworkOpcode.WorldChatSend: @@ -272,21 +292,23 @@ namespace XWorld.Server.Gateway } } - private void EnqueueRandomMatch(int pid) + private void EnqueueRandomMatch(int pid, int requestId) { var games = BuildGameList().Games; if (games.Count == 0) { SendFramework(pid, FrameworkOpcode.Error, - new ErrorMsg { Code = 1, Message = "no minigame available" }.Encode()); + new ErrorMsg { Code = 1, Message = "no minigame available", RequestId = requestId }.Encode()); return; } var selected = games[_random.Next(games.Count)]; - _logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}"); - SendFramework(pid, FrameworkOpcode.MatchAssigned, - new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version }.Encode()); - EnqueueMatch(pid, selected.GameId, selected.Version); + if (EnqueueMatch(pid, selected.GameId, selected.Version, requestId)) + { + _logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}"); + SendFramework(pid, FrameworkOpcode.MatchAssigned, + new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version, RequestId = requestId }.Encode()); + } } private GameListResponseMsg BuildGameList() @@ -332,17 +354,17 @@ namespace XWorld.Server.Gateway return msg; } - private void EnqueueMatch(int pid, string gameId, int version) + private bool EnqueueMatch(int pid, string gameId, int version, int requestId = 0) { var s = _sessions.Get(pid); - if (s == null) return; // 未建立会话,忽略 + if (s == null) return false; // 未建立会话,忽略 if (s.RoomId != null || _queuedPids.Contains(pid)) { // 已在房间内或已在队列中:拒绝 SendFramework(pid, FrameworkOpcode.Error, - new ErrorMsg { Code = 2, Message = "already in room or queue" }.Encode()); - return; + new ErrorMsg { Code = 2, Message = "already in room or queue", RequestId = requestId }.Encode()); + return false; } // 读取游戏的 playerCount(缓存) @@ -360,14 +382,76 @@ namespace XWorld.Server.Gateway { _logger.Error($"读取 game.json 失败 {gameId}@{version}: {ex.Message}"); SendFramework(pid, FrameworkOpcode.Error, - new ErrorMsg { Code = 1, Message = "game not found" }.Encode()); - return; + new ErrorMsg { Code = 1, Message = "game not found", RequestId = requestId }.Encode()); + return false; } } _queuedPids.Add(pid); - _matchmaker.Enqueue(pid, gameId, version, playerCount, _tick); - _logger.Info($"enqueue match pid={pid} game={gameId}@{version} playerCount={playerCount} tick={_tick}"); + _queuedRequestIds[pid] = requestId; + _matchmaker.Enqueue(pid, gameId, version, playerCount, _tick, requestId); + _logger.Info($"enqueue match pid={pid} req={requestId} game={gameId}@{version} playerCount={playerCount} tick={_tick}"); + return true; + } + + private void CancelQueuedMatch(int pid, int requestId = 0) + { + if (requestId != 0 && + _queuedRequestIds.TryGetValue(pid, out int queuedRequestId) && + queuedRequestId != requestId) + { + return; + } + + if (_queuedPids.Remove(pid)) + { + _queuedRequestIds.Remove(pid); + _matchmaker.Remove(pid); + _logger.Info($"cancel queued match pid={pid} req={requestId}"); + } + } + + private void CancelMatchState(int pid, int requestId) + { + CancelQueuedMatch(pid, requestId); + CancelActiveRoom(pid, requestId); + } + + private void CancelActiveRoom(int pid, int requestId) + { + var s = _sessions.Get(pid); + if (s?.RoomId == null) return; + if (requestId != 0 && + _activeRoomRequestIds.TryGetValue(pid, out int activeRequestId) && + activeRequestId != requestId) + { + return; + } + + string roomId = s.RoomId; + + if (_roomPlayers.TryGetValue(roomId, out var players)) + { + var roomPids = new List(players); + _roomPlayers.Remove(roomId); + _activeRooms.Remove(roomId); + for (int i = 0; i < roomPids.Count; i++) + { + int roomPid = roomPids[i]; + var roomSession = _sessions.Get(roomPid); + if (roomSession?.RoomId == roomId) roomSession.RoomId = null; + _activeRoomRequestIds.Remove(roomPid); + } + _host.EndRoom(roomId); + } + else + { + _activeRooms.Remove(roomId); + s.RoomId = null; + _activeRoomRequestIds.Remove(pid); + } + + _logger.Info($"cancel active match pid={pid} req={requestId} room={roomId}"); } private void CreateRoomForSeats(Matchmaker.Match match) @@ -397,10 +481,23 @@ namespace XWorld.Server.Gateway { realPids.Add(seat.PlayerId); _queuedPids.Remove(seat.PlayerId); + _queuedRequestIds.Remove(seat.PlayerId); } } - var sink = new RoomOutputSink(roomId, realPids, _sessions); + var allPlayers = new List(playerInfos); + var roomInfo = new ActiveRoomInfo + { + RoomId = roomId, + GameId = match.GameId, + Version = match.Version, + Players = allPlayers + }; + var sink = new RoomOutputSink(roomId, realPids, _sessions, message => + { + roomInfo.HasLastGameMessage = true; + roomInfo.LastGameMessage = new NetMessage(message.Opcode, message.Payload ?? Array.Empty()); + }); var cfg = new RoomConfig { GameId = match.GameId, @@ -409,6 +506,7 @@ namespace XWorld.Server.Gateway PlayerCount = seats.Count }; + _activeRooms[roomId] = roomInfo; Room room; try { @@ -417,6 +515,7 @@ namespace XWorld.Server.Gateway catch (Exception ex) { _logger.Error($"建房失败 {match.GameId}@{match.Version}: {ex.Message}"); + _activeRooms.Remove(roomId); // Notify real players of error var errPayload = new ErrorMsg { Code = 1, Message = "create room failed" }.Encode(); foreach (int rpid in realPids) @@ -424,20 +523,30 @@ namespace XWorld.Server.Gateway return; } - // Build MatchFound message with all players info - var allPlayers = new List(playerInfos); - // For each real player: set session.RoomId + send MatchFound foreach (int rpid in realPids) { var s = _sessions.Get(rpid); if (s != null) s.RoomId = roomId; + int requestId = 0; + for (int i = 0; i < seats.Count; i++) + { + if (!seats[i].IsAi && seats[i].PlayerId == rpid) + { + requestId = seats[i].RequestId; + break; + } + } + _activeRoomRequestIds[rpid] = requestId; + roomInfo.RequestIds[rpid] = requestId; var found = new MatchFoundMsg { + GameId = match.GameId, RoomId = roomId, Version = match.Version, - SelfPlayerId = rpid + SelfPlayerId = rpid, + RequestId = requestId }; foreach (var pi in allPlayers) found.Players.Add(pi); SendFramework(rpid, FrameworkOpcode.MatchFound, found.Encode()); @@ -452,6 +561,7 @@ namespace XWorld.Server.Gateway { if (!_roomPlayers.TryGetValue(roomId, out var players)) return; _roomPlayers.Remove(roomId); + _activeRooms.Remove(roomId); var msg = new RoomEndMsg { WinnerPlayerId = result?.WinnerPlayerId ?? 0, @@ -464,6 +574,7 @@ namespace XWorld.Server.Gateway SendFramework(pid, FrameworkOpcode.RoomEnd, payload); var s = _sessions.Get(pid); if (s != null) s.RoomId = null; + _activeRoomRequestIds.Remove(pid); } } @@ -472,5 +583,36 @@ namespace XWorld.Server.Gateway _sessions.GetConnection(pid)?.Send( FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload))); } + + private void SendGame(int pid, NetMessage message) + { + _sessions.GetConnection(pid)?.Send( + FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload))); + } + + private void SendRoomResumeIfNeeded(int pid) + { + var session = _sessions.Get(pid); + if (session?.RoomId == null) return; + if (!_activeRooms.TryGetValue(session.RoomId, out var room)) + { + session.RoomId = null; + _activeRoomRequestIds.Remove(pid); + return; + } + + var found = new MatchFoundMsg + { + GameId = room.GameId, + RoomId = room.RoomId, + Version = room.Version, + SelfPlayerId = pid, + RequestId = room.RequestIds.TryGetValue(pid, out int requestId) ? requestId : 0 + }; + for (int i = 0; i < room.Players.Count; i++) found.Players.Add(room.Players[i]); + SendFramework(pid, FrameworkOpcode.MatchFound, found.Encode()); + if (room.HasLastGameMessage) SendGame(pid, room.LastGameMessage); + _logger.Info($"resume room pid={pid} room={room.RoomId} game={room.GameId}@{room.Version}"); + } } } diff --git a/Server/Server.Host.Tests/RoomHostTests.cs b/Server/Server.Host.Tests/RoomHostTests.cs index 6a78fcaa..133d878a 100644 --- a/Server/Server.Host.Tests/RoomHostTests.cs +++ b/Server/Server.Host.Tests/RoomHostTests.cs @@ -74,6 +74,23 @@ namespace XWorld.Server.Host.Tests () => host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture())); } + [Fact] + public void EndRoom_ReleasesRoomImmediately() + { + var host = NewHost(); + var cap = new Capture(); + host.CreateRoom("manual", "rps", 1, Cfg(), Players, cap); + + Assert.True(host.EndRoom("manual")); + Assert.Equal(0, host.ActiveRoomCount); + + int broadcasts = cap.Broadcasts.Count; + host.DeliverTo("manual", 1, new NetMessage(1, new byte[] { (byte)Choice.Rock })); + host.TickAll(0.1f); + + Assert.Equal(broadcasts, cap.Broadcasts.Count); + } + [Fact] public void TickAll_ReturnsEndedRoomIds() { diff --git a/Server/Server.Host/RoomHost.cs b/Server/Server.Host/RoomHost.cs index 9cb543b2..f3a1f274 100644 --- a/Server/Server.Host/RoomHost.cs +++ b/Server/Server.Host/RoomHost.cs @@ -84,6 +84,14 @@ namespace XWorld.Server.Host entry.Room.Deliver(playerId, message); } + public bool EndRoom(string roomId) + { + if (!_rooms.TryGetValue(roomId, out var entry)) return false; + entry.Room.End(); + ReleaseRoom(roomId); + return true; + } + private void ReleaseRoom(string roomId) { if (!_rooms.TryGetValue(roomId, out var entry)) return;