diff --git a/DXMainClient/DXGUI/GameClass.cs b/DXMainClient/DXGUI/GameClass.cs index baab76846..405bf9cde 100644 --- a/DXMainClient/DXGUI/GameClass.cs +++ b/DXMainClient/DXGUI/GameClass.cs @@ -282,6 +282,7 @@ private IServiceProvider BuildServiceProvider(WindowManager windowManager) .AddSingletonXnaControl() .AddSingletonXnaControl() .AddSingletonXnaControl() + .AddSingletonXnaControl() .AddSingletonXnaControl() .AddSingletonXnaControl() .AddSingletonXnaControl() diff --git a/DXMainClient/DXGUI/Generic/GameSessionDropDown.cs b/DXMainClient/DXGUI/Generic/GameSessionDropDown.cs index 3a47bf4a8..f31009024 100644 --- a/DXMainClient/DXGUI/Generic/GameSessionDropDown.cs +++ b/DXMainClient/DXGUI/Generic/GameSessionDropDown.cs @@ -1,4 +1,4 @@ -using System; +using System; using ClientCore.Extensions; using ClientCore.I18N; @@ -88,6 +88,7 @@ static string Localize(XNAControl control, string attributeName, string defaultV switch (key) { case "Items": + Items.Clear(); string[] items = value.SplitWithCleanup(); string[] itemLabels = iniFile.GetStringListValue(Name, "ItemLabels", ""); string[] iconNames = iniFile.GetStringListValue(Name, "Icons", ""); diff --git a/DXMainClient/DXGUI/Generic/MainMenu.cs b/DXMainClient/DXGUI/Generic/MainMenu.cs index d24a42a8f..d82a87019 100644 --- a/DXMainClient/DXGUI/Generic/MainMenu.cs +++ b/DXMainClient/DXGUI/Generic/MainMenu.cs @@ -61,6 +61,7 @@ public MainMenu( ManualUpdateQueryWindow manualUpdateQueryWindow, UpdateWindow updateWindow, ExtrasWindow extrasWindow, + MatchFoundWindow matchFoundWindow, DirectDrawWrapperManager directDrawWrapperManager ) : base(windowManager) { @@ -84,6 +85,7 @@ DirectDrawWrapperManager directDrawWrapperManager this.manualUpdateQueryWindow = manualUpdateQueryWindow; this.updateWindow = updateWindow; this.extrasWindow = extrasWindow; + this.matchFoundWindow = matchFoundWindow; this.directDrawWrapperManager = directDrawWrapperManager; this.cncnetLobby.UpdateCheck += CncnetLobby_UpdateCheck; @@ -120,6 +122,7 @@ DirectDrawWrapperManager directDrawWrapperManager private readonly ManualUpdateQueryWindow manualUpdateQueryWindow; private readonly UpdateWindow updateWindow; private readonly ExtrasWindow extrasWindow; + private readonly MatchFoundWindow matchFoundWindow; private readonly DirectDrawWrapperManager directDrawWrapperManager; private XNAMessageBox firstRunMessageBox; @@ -630,6 +633,7 @@ public void PostInit() topBar.SetTertiarySwitch(privateMessagingWindow); topBar.SetOptionsWindow(optionsWindow); WindowManager.AddAndInitializeControl(gameInProgressWindow); + WindowManager.AddAndInitializeControl(matchFoundWindow); foreach (XNAControl control in new XNAControl[] { diff --git a/DXMainClient/DXGUI/Generic/MatchFoundWindow.cs b/DXMainClient/DXGUI/Generic/MatchFoundWindow.cs new file mode 100644 index 000000000..ad906fd2c --- /dev/null +++ b/DXMainClient/DXGUI/Generic/MatchFoundWindow.cs @@ -0,0 +1,77 @@ +using Rampastring.XNAUI.XNAControls; +using System; +using ClientCore; +using Rampastring.XNAUI; +using ClientGUI; +using ClientCore.Extensions; +using Rectangle = Microsoft.Xna.Framework.Rectangle; +using Color = Microsoft.Xna.Framework.Color; + +namespace DTAClient.DXGUI.Generic +{ + /// + /// Displays a dialog in the client when a matchmaking match is found. + /// Blocks user interaction until the game process starts. + /// + public class MatchFoundWindow : XNAPanel + { + public MatchFoundWindow(WindowManager windowManager) : base(windowManager) + { + } + + private bool initialized = false; + + public override void Initialize() + { + if (initialized) + throw new InvalidOperationException("MatchFoundWindow cannot be initialized twice!"); + + initialized = true; + + BackgroundTexture = AssetLoader.CreateTexture(new Color(0, 0, 0, 128), 1, 1); + PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED; + DrawBorders = false; + ClientRectangle = new Rectangle(0, 0, WindowManager.RenderResolutionX, WindowManager.RenderResolutionY); + + XNAWindow window = new XNAWindow(WindowManager); + window.Name = "MatchFoundWindowBox"; + window.BackgroundTexture = AssetLoader.CreateTexture(new Color(0, 0, 0, 220), 2, 2); + window.PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED; + window.ClientRectangle = new Rectangle(0, 0, 350, 120); + + XNALabel explanation = new XNALabel(WindowManager); + explanation.FontIndex = 1; + explanation.Text = "Match found! Joining room...".L10N("Client:Main:MatchFoundLabel"); + + AddChild(window); + window.AddChild(explanation); + + base.Initialize(); + + explanation.CenterOnParent(); + window.CenterOnParent(); + + Visible = false; + Enabled = false; + + GameProcessLogic.GameProcessStarted += SharedUILogic_GameProcessStarted; + } + + private void SharedUILogic_GameProcessStarted() + { + Hide(); + } + + public void Show() + { + Visible = true; + Enabled = true; + } + + public void Hide() + { + Visible = false; + Enabled = false; + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index d7dba1fbb..431d44ade 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -1,4 +1,4 @@ -using ClientCore; +using ClientCore; using ClientGUI; using DTAClient.Domain.Multiplayer; using DTAClient.Domain.Multiplayer.CnCNet; @@ -37,7 +37,8 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, CnCNetGameLobby gameLobby, CnCNetGameLoadingLobby gameLoadingLobby, TopBar topBar, PrivateMessagingWindow pmWindow, TunnelHandler tunnelHandler, GameCollection gameCollection, CnCNetUserData cncnetUserData, - OptionsWindow optionsWindow, MapLoader mapLoader, Random random) + OptionsWindow optionsWindow, MatchFoundWindow matchFoundWindow, + MapLoader mapLoader, Random random) : base(windowManager) { this.connectionManager = connectionManager; @@ -49,23 +50,25 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, this.gameCollection = gameCollection; this.cncnetUserData = cncnetUserData; this.optionsWindow = optionsWindow; + this.matchFoundWindow = matchFoundWindow; this.mapLoader = mapLoader; this.random = random; ctcpCommandHandlers = new CommandHandlerBase[] { new StringCommandHandler(ProgramConstants.GAME_INVITE_CTCP_COMMAND, HandleGameInviteCommand), - new NoParamCommandHandler(ProgramConstants.GAME_INVITATION_FAILED_CTCP_COMMAND, HandleGameInvitationFailedNotification) + new NoParamCommandHandler(ProgramConstants.GAME_INVITATION_FAILED_CTCP_COMMAND, HandleGameInvitationFailedNotification), + new StringCommandHandler(MatchmakingService.PrivateJoinCommandName, HandleMatchmakingRoomInvitation) }; topBar.LogoutEvent += LogoutEvent; } - private MapLoader mapLoader; - private CnCNetManager connectionManager; private CnCNetUserData cncnetUserData; private readonly OptionsWindow optionsWindow; + private readonly MatchFoundWindow matchFoundWindow; + private readonly MapLoader mapLoader; private PlayerListBox lbPlayerList; private ChatListBox lbChatMessages; @@ -73,6 +76,7 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private GlobalContextMenu globalContextMenu; private XNAClientButton btnLogout; + private XNAClientButton btnMatchmaking; private XNAClientButton btnNewGame; private XNAClientButton btnJoinGame; @@ -91,10 +95,14 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private XNAClientStateButton btnGameSortAlpha; private XNAClientToggleButton btnGameFilterOptions; + private XNAClientDropDown ddMatchmakingMode; + private XNALabel lblMatchmakingMode; private DarkeningPanel gameCreationPanel; private Channel currentChatChannel; + private DateTime lastMatchmakingClickTime = DateTime.MinValue; + private GameCollection gameCollection; @@ -119,6 +127,7 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private PrivateMessagingWindow pmWindow; + private XNAPanel passwordRequestWindowPanel; private PasswordRequestWindow passwordRequestWindow; private bool isInGameRoom = false; @@ -146,9 +155,34 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private bool ctcpNoTunnelMessageShown = false; private bool ctcpNoTunnelForGamesMessageShown = false; + private MatchmakingService matchmakingService; + private readonly HashSet hiddenMatchmakingChannels = new(StringComparer.OrdinalIgnoreCase); + private readonly bool matchmakingAutoTestEnabled = + string.Equals(Environment.GetEnvironmentVariable("MM_AUTOTEST"), "1", StringComparison.OrdinalIgnoreCase); + private bool matchmakingAutoTestStarted; + + private List pendingMatchmakingParticipants; + private string pendingMatchmakingMode; + + private string lastCreatedGameChannelName; + private string lastCreatedGamePassword; + private string lastCreatedGameRoomName; + private CnCNetTunnel lastCreatedGameTunnel; + private int lastCreatedGameMaxPlayers; + private int lastCreatedGameSkillLevel; + private const int MatchmakingModeWidth = 90; + private const int CurrentChannelWidth = 200; + private const int CurrentChannelLabelWidth = 150; + private const int TopControlsSpacing = 8; + private const QueuedMessageType MatchmakingQueueMessageType = QueuedMessageType.INSTANT_MESSAGE; + private const int MatchmakingQueueMessagePriority = 0; + private const QueuedMessageType MatchmakingInviteMessageType = QueuedMessageType.INSTANT_MESSAGE; + private const int MatchmakingInviteMessagePriority = 0; + private void GameList_ClientRectangleUpdated(object sender, EventArgs e) { panelGameFilters.ClientRectangle = lbGameList.ClientRectangle; + LayoutTopLobbyControls(); } private void LogoutEvent(object sender, EventArgs e) @@ -168,9 +202,17 @@ public override void Initialize() localGameID = ClientConfiguration.Instance.LocalGame; localGame = gameCollection.GameList.Find(g => g.InternalName.ToUpper() == localGameID.ToUpper()); + btnMatchmaking = new XNAClientButton(WindowManager); + btnMatchmaking.Name = nameof(btnMatchmaking); + btnMatchmaking.ClientRectangle = new Rectangle(12, Height - 29, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); + btnMatchmaking.Text = "Matchmaking".L10N("Client:Main:Matchmaking"); + btnMatchmaking.AllowClick = false; + btnMatchmaking.LeftClick += BtnMatchmaking_LeftClick; + btnNewGame = new XNAClientButton(WindowManager); btnNewGame.Name = nameof(btnNewGame); - btnNewGame.ClientRectangle = new Rectangle(12, Height - 29, UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); + btnNewGame.ClientRectangle = new Rectangle(btnMatchmaking.Right + 12, btnMatchmaking.Y, + UIDesignConstants.BUTTON_WIDTH_133, UIDesignConstants.BUTTON_HEIGHT); btnNewGame.Text = "Create Game".L10N("Client:Main:CreateGame"); btnNewGame.AllowClick = false; btnNewGame.LeftClick += BtnNewGame_LeftClick; @@ -199,6 +241,7 @@ public override void Initialize() panelGameFilters.Name = nameof(panelGameFilters); panelGameFilters.ClientRectangle = gameListRectangle; panelGameFilters.Disable(); + panelGameFilters.Visible = false; lbGameList = new GameListBox(WindowManager, mapLoader, localGameID, gameLobby, HostedGameMatches); lbGameList.Name = nameof(lbGameList); @@ -341,8 +384,49 @@ public override void Initialize() btnGameFilterOptions.SetToolTipText("Game Filters".L10N("Client:Main:GameFilters")); RefreshGameFiltersBtn(); + ddMatchmakingMode = new XNAClientDropDown(WindowManager); + ddMatchmakingMode.Name = nameof(ddMatchmakingMode); + ddMatchmakingMode.ClientRectangle = new Rectangle( + lbGameList.X, + tbGameSearch.Y, + MatchmakingModeWidth, + UIDesignConstants.BUTTON_HEIGHT); + + lblMatchmakingMode = new XNALabel(WindowManager); + lblMatchmakingMode.Name = nameof(lblMatchmakingMode); + lblMatchmakingMode.FontIndex = 1; + lblMatchmakingMode.Text = "MATCH MODE:"; + MatchmakingSettings.Instance.Initialize(); + MatchmakingMapDefinitions.Instance.Initialize(); + if (MatchmakingSettings.Instance.DebugMode) + { + WindowManager.AddCallback(new Action(() => { + AddMainChannelNotice("--- Matchmaking Debug Mode is ACTIVE ---"); + }), null); + } + foreach (var mode in MatchmakingSettings.Instance.Modes) + { + ddMatchmakingMode.AddItem(new XNADropDownItem { Text = mode.UIName }); + } + if (ddMatchmakingMode.Items.Count > 0) + ddMatchmakingMode.SelectedIndex = 0; + ddMatchmakingMode.AllowDropDown = true; + + matchmakingService = new MatchmakingService( + random, + () => ProgramConstants.PLAYERNAME, + GetSelectedMatchmakingMode, + CanJoinMatchmakingQueue, + CanHostMatchmakingQueue, + GetRequiredPlayersForMode, + SendMatchmakingChannelCommand, + AddMainChannelNotice, + SetMatchmakingQueueUiState, + OnLocalMatchClaimed); + InitializeGameList(); + AddChild(btnMatchmaking); AddChild(btnNewGame); AddChild(btnJoinGame); AddChild(btnLogout); @@ -359,6 +443,8 @@ public override void Initialize() AddChild(lblOnline); AddChild(lblOnlineCount); AddChild(tbGameSearch); + AddChild(ddMatchmakingMode); + AddChild(lblMatchmakingMode); AddChild(btnGameSortAlpha); AddChild(btnGameFilterOptions); @@ -371,6 +457,8 @@ public override void Initialize() pmWindow.SetJoinUserAction(JoinUser); base.Initialize(); + AlignMatchmakingButtonWithMainButtons(); + LayoutTopLobbyControls(); WindowManager.CenterControlOnScreen(this); @@ -534,6 +622,22 @@ private void InitializeGameList() item.Tag = chatChannel; + if (game.InternalName.ToUpper() == localGameID.ToUpper()) + { + string mmName = game.ChatChannel + "-mm"; + var mmChannel = connectionManager.FindChannel(mmName); + if (mmChannel == null) + { + mmChannel = connectionManager.CreateChannel(game.UIName + " Matchmaking", mmName, true, false, null); + connectionManager.AddChannel(mmChannel); + } + mmChannel.CTCPReceived += MatchmakingChatChannel_CTCPReceived; + mmChannel.UserAdded += MatchmakingChatChannel_UserAdded; + mmChannel.UserLeft += MatchmakingChatChannel_UserLeftOrQuit; + mmChannel.UserQuitIRC += MatchmakingChatChannel_UserLeftOrQuit; + mmChannel.UserKicked += MatchmakingChatChannel_UserLeftOrQuit; + } + if (!string.IsNullOrEmpty(game.GameBroadcastChannel)) { var gameBroadcastChannel = connectionManager.FindChannel(game.GameBroadcastChannel); @@ -822,7 +926,533 @@ private void SetLogOutButtonText() btnLogout.Text = "Log Out".L10N("Client:Main:LogOut"); } - private void BtnJoinGame_LeftClick(object sender, EventArgs e) => JoinSelectedGame(); + private void BtnJoinGame_LeftClick(object sender, EventArgs e) + { + if (matchmakingService?.IsInQueue == true) + { + matchmakingService.LeaveQueue(true, false); + } + + JoinSelectedGame(); + } + + private void BtnMatchmaking_LeftClick(object sender, EventArgs e) + { + if (DateTime.Now - lastMatchmakingClickTime < TimeSpan.FromSeconds(2)) + { + Logger.Log($"[Matchmaking] { "BtnClickThrottled" }: { "Click ignored due to 2s cooldown" }"); + return; + } + + lastMatchmakingClickTime = DateTime.Now; + + if (gameLobby.Enabled) + { + Logger.Log($"[Matchmaking] { "BtnClickLeavingLobby" }: { "Leaving lobby before matchmaking toggle" }"); + gameLobby.LeaveGameLobby(); + isInGameRoom = false; + } + + if (gameLoadingLobby.Enabled) + { + gameLoadingLobby.Clear(); + isInGameRoom = false; + } + + matchmakingService?.ToggleQueue(); + } + + private string GetSelectedMatchmakingMode() => + ddMatchmakingMode.SelectedItem?.Text ?? "1v1"; + + private int GetRequiredPlayersForMode(string mode) + { + var def = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, mode, StringComparison.OrdinalIgnoreCase)); + return def != null ? def.PlayerCount : 2; + } + + private bool CanJoinMatchmakingQueue() + { + if (isInGameRoom || gameLobby.Enabled || gameLoadingLobby.Enabled || isJoiningGame || ProgramConstants.IsInGame) + { + Logger.Log($"[Matchmaking] { "CannotJoinQueue" }: { $"isInGameRoom={isInGameRoom}, gameLobby={gameLobby.Enabled}, gameLoadingLobby={gameLoadingLobby.Enabled}, isJoiningGame={isJoiningGame}, IsInGame={ProgramConstants.IsInGame}" }"); + return false; + } + if (isInGameRoom || gameLobby.Enabled || gameLoadingLobby.Enabled || isJoiningGame || ProgramConstants.IsInGame) + return false; + + return tunnelHandler.CurrentTunnel != null || + (tunnelHandler.Tunnels != null && tunnelHandler.Tunnels.Count > 0); + } + + private bool CanHostMatchmakingQueue() => + !isInGameRoom && !gameLobby.Enabled && !gameLoadingLobby.Enabled && !isJoiningGame && !ProgramConstants.IsInGame; + + private void SetMatchmakingQueueUiState(bool queued) + { + if (ddMatchmakingMode != null) + ddMatchmakingMode.AllowDropDown = !queued; + + if (btnMatchmaking != null) + btnMatchmaking.Text = queued ? "Leave Queue" : "Matchmaking".L10N("Client:Main:Matchmaking"); + } + + private void AlignMatchmakingButtonWithMainButtons() + { + int matchmakingX = Math.Max(12, btnNewGame.X - UIDesignConstants.BUTTON_WIDTH_133 - 12); + btnMatchmaking.ClientRectangle = new Rectangle( + matchmakingX, + btnNewGame.Y, + UIDesignConstants.BUTTON_WIDTH_133, + UIDesignConstants.BUTTON_HEIGHT); + } + + private void LayoutTopLobbyControls() + { + if (tbGameSearch == null || btnGameSortAlpha == null || btnGameFilterOptions == null || lbGameList == null) + return; + + const int iconSize = 21; + const int minSearchWidth = 120; + + int left = lbGameList.X; + int y = tbGameSearch.Y; + int right = lbGameList.Right; + + if (lbChatMessages != null) + right = Math.Min(right, lbChatMessages.X - TopControlsSpacing); + + if (lblOnline != null && lblOnline.Visible && lblOnline.Enabled) + right = Math.Min(right, lblOnline.X - TopControlsSpacing); + + int requiredWidth = iconSize + iconSize + (TopControlsSpacing * 2) + minSearchWidth; + if (right - left < requiredWidth) + right = lbGameList.Right; + + int searchWidth = right - left - iconSize - iconSize - (TopControlsSpacing * 2); + if (searchWidth < minSearchWidth) + searchWidth = minSearchWidth; + + tbGameSearch.ClientRectangle = new Rectangle( + left, + y, + searchWidth, + tbGameSearch.Height); + + btnGameSortAlpha.ClientRectangle = new Rectangle( + tbGameSearch.Right + TopControlsSpacing, + y, + iconSize, + iconSize); + + btnGameFilterOptions.ClientRectangle = new Rectangle( + btnGameSortAlpha.Right + TopControlsSpacing, + y, + iconSize, + iconSize); + LayoutCurrentChannelAndMatchmakingModeControls(); + } + + private void LayoutCurrentChannelAndMatchmakingModeControls() + { + if (ddCurrentChannel == null || lblCurrentChannel == null || ddMatchmakingMode == null || lbChatMessages == null || ddColor == null || lblMatchmakingMode == null) + return; + + int y = tbGameSearch != null ? tbGameSearch.Y : ddColor.Y; + int rightAlign = lbChatMessages.Right; + + // 1. Color Dropdown (far right) + ddColor.ClientRectangle = new Rectangle( + rightAlign - 140, + y, + 140, + ddColor.Height); + int currentLeft = ddColor.X; + + // 2. Current Channel Dropdown + currentLeft -= (TopControlsSpacing + CurrentChannelWidth); + ddCurrentChannel.ClientRectangle = new Rectangle( + currentLeft, + y, + CurrentChannelWidth, + ddCurrentChannel.Height); + + // 3. Current Channel Label + // Try to measure the actual width to make it dynamic + int labelWidth = lblCurrentChannel.Width > 0 ? lblCurrentChannel.Width : 100; + currentLeft -= (labelWidth + TopControlsSpacing); + lblCurrentChannel.ClientRectangle = new Rectangle( + currentLeft, + y + 2, + 0, + 0); + + // 4. Matchmaking Mode Dropdown + // We need to ensure we don't overlap with the left-side controls + int leftLimit = btnGameFilterOptions != null ? btnGameFilterOptions.Right + TopControlsSpacing : lbGameList.X; + // Also check for online count if visible + if (lblOnlineCount != null && lblOnlineCount.Visible) + leftLimit = Math.Max(leftLimit, lblOnlineCount.Right + TopControlsSpacing); + + // Position matchmaking mode relative to current channel + int matchmakingLabelWidth = lblMatchmakingMode.Width > 0 ? lblMatchmakingMode.Width : 85; + int totalSpaceNeeded = MatchmakingModeWidth + matchmakingLabelWidth + (TopControlsSpacing * 2); + + // If we have enough space to the left of the current channel label + int modeDropdownX = lblCurrentChannel.X - TopControlsSpacing - MatchmakingModeWidth; + + // Safety check: if it overlaps with left side, we might need to push it right or hide things + if (modeDropdownX - matchmakingLabelWidth - TopControlsSpacing < leftLimit) + { + // Not enough space, cap it + modeDropdownX = leftLimit + matchmakingLabelWidth + TopControlsSpacing; + // If it now overlaps with the right-side controls, we have a tiny resolution + if (modeDropdownX + MatchmakingModeWidth > lblCurrentChannel.X) + { + // Very tight space, just hide matchmaking label or stack them (complicated) + // For now, let's just let it overlap slightly or hide matchmaking label + lblMatchmakingMode.Visible = false; + modeDropdownX = leftLimit; + } + } + else + { + lblMatchmakingMode.Visible = true; + } + + ddMatchmakingMode.ClientRectangle = new Rectangle( + modeDropdownX, + y, + MatchmakingModeWidth, + UIDesignConstants.BUTTON_HEIGHT); + + // 5. Matchmaking Mode Label + lblMatchmakingMode.ClientRectangle = new Rectangle( + ddMatchmakingMode.X - (lblMatchmakingMode.Visible ? matchmakingLabelWidth + TopControlsSpacing : 0), + y + 2, + 0, + 0); + } + + private void AddMainChannelNotice(string message) => + connectionManager.MainChannel?.AddMessage(new ChatMessage(Color.White, message)); + + private void MatchmakingChatChannel_CTCPReceived(object sender, ChannelCTCPEventArgs e) + { + string prefix = MatchmakingService.ChannelCommandName + " "; + if (!e.Message.StartsWith(prefix, StringComparison.Ordinal)) + return; + + matchmakingService?.HandleChannelCommand(e.UserName, e.Message.Substring(prefix.Length)); + } + + private void MatchmakingChatChannel_UserAdded(object sender, ChannelUserEventArgs e) + { + if (!matchmakingAutoTestEnabled || matchmakingAutoTestStarted) + return; + + if (!string.Equals(e.User.IRCUser.Name, ProgramConstants.PLAYERNAME, StringComparison.OrdinalIgnoreCase)) + return; + + // Start once local player has fully joined the local game chat channel. + // This mirrors a real button click for smoke testing. + if (ddMatchmakingMode != null && ddMatchmakingMode.Items.Count > 0) + ddMatchmakingMode.SelectedIndex = 0; + + if (CanJoinMatchmakingQueue()) + { + matchmakingAutoTestStarted = true; + Logger.Log($"[Matchmaking] { "AutoTestJoinQueue" }: { "mode=1v1" }"); + matchmakingService?.ToggleQueue(); + } + else + { + Logger.Log($"[Matchmaking] WARNING { "AutoTestJoinQueueSkipped" }: { "reason=client_not_ready" }"); + } + } + + private void MatchmakingChatChannel_UserLeftOrQuit(object sender, UserNameEventArgs e) => + matchmakingService?.HandleUserLeftOrQuit(e.UserName); + + private void OnLocalMatchClaimed(string mode, List participants) => + TryCreateMatchmakingRoom(mode, participants); + + private void TryCreateMatchmakingRoom(string mode, List participants) + { + try + { + if (participants == null || participants.Count == 0) + return; + + if (gameLobby.Enabled || gameLoadingLobby.Enabled || isInGameRoom || isJoiningGame) + return; + + CnCNetTunnel selectedTunnel = tunnelHandler.CurrentTunnel ?? tunnelHandler.Tunnels?.FirstOrDefault(); + if (selectedTunnel == null) + { + Logger.Log($"[Matchmaking] WARNING { "CreateRoomFailed" }: { "reason=no_tunnel" }"); + AddMainChannelNotice("Matchmaking failed: no tunnel server is available."); + return; + } + + int maxPlayers = GetRequiredPlayersForMode(mode); + string roomName = $"{mode} Match {random.Next(1000, 9999)}"; + + pendingMatchmakingParticipants = participants + .Where(p => !string.Equals(p, ProgramConstants.PLAYERNAME, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + pendingMatchmakingMode = mode; + + Logger.Log($"[Matchmaking] { "CreateRoomRequested" }: { $"mode={mode}, maxPlayers={maxPlayers}, participants={string.Join(",", participants)}" }"); + + string previousCreatedChannelName = lastCreatedGameChannelName; + if (!MatchmakingSettings.Instance.DebugMode) + matchFoundWindow.Show(); + Gcw_GameCreated(this, new GameCreationEventArgs(roomName, maxPlayers, string.Empty, selectedTunnel, 0)); + + if (pendingMatchmakingParticipants != null && + string.Equals(previousCreatedChannelName, lastCreatedGameChannelName, StringComparison.OrdinalIgnoreCase)) + { + pendingMatchmakingParticipants = null; + pendingMatchmakingMode = null; + Logger.Log($"[Matchmaking] WARNING { "CreateRoomFailed" }: { "reason=no_new_channel_created" }"); + AddMainChannelNotice("Matchmaking failed: unable to create a room."); + return; + } + } + catch (Exception ex) + { + Logger.Log($"[Matchmaking] ERROR { "CreateRoomException" }: { ex }"); + AddMainChannelNotice("Matchmaking failed: unexpected error while creating room."); + } + } + + private void TrySendMatchmakingParticipantsToRoom(string hostName) + { + if (pendingMatchmakingParticipants == null || pendingMatchmakingParticipants.Count == 0) + return; + + if (string.IsNullOrEmpty(lastCreatedGameChannelName) || + string.IsNullOrEmpty(lastCreatedGamePassword) || + lastCreatedGameTunnel == null) + { + return; + } + + string commandParameters = + $"{pendingMatchmakingMode};{lastCreatedGameChannelName};{lastCreatedGameRoomName};{lastCreatedGamePassword};{lastCreatedGameMaxPlayers};{lastCreatedGameSkillLevel};{lastCreatedGameTunnel.Address};{lastCreatedGameTunnel.Port}"; + + Logger.Log($"[Matchmaking] { "InvitePrepared" }: { $"mode={pendingMatchmakingMode}, host={hostName}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, participants={string.Join(",", pendingMatchmakingParticipants)}, messageType={MatchmakingInviteMessageType}, priority={MatchmakingInviteMessagePriority}" }"); + + foreach (string participant in pendingMatchmakingParticipants) + { + if (string.Equals(participant, hostName, StringComparison.OrdinalIgnoreCase) || + string.Equals(participant, ProgramConstants.PLAYERNAME, StringComparison.OrdinalIgnoreCase)) + { + Logger.Log($"[Matchmaking] { "InviteSkippedHost" }: { $"mode={pendingMatchmakingMode}, host={hostName}, participant={participant}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}" }"); + continue; + } + + connectionManager.SendCustomMessage(new QueuedMessage( + $"PRIVMSG {participant} :\u0001{MatchmakingService.PrivateJoinCommandName} {commandParameters}\u0001", + MatchmakingInviteMessageType, MatchmakingInviteMessagePriority)); + + Logger.Log($"[Matchmaking] { "InviteSent" }: { $"mode={pendingMatchmakingMode}, host={hostName}, participant={participant}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, messageType={MatchmakingInviteMessageType}, priority={MatchmakingInviteMessagePriority}" }"); + } + + Logger.Log($"[Matchmaking] { "InvitesSent" }: { $"mode={pendingMatchmakingMode}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, participants={string.Join(",", pendingMatchmakingParticipants)}" }"); + + AddMainChannelNotice($"Match found ({pendingMatchmakingMode}). Room created on {hostName}."); + + pendingMatchmakingParticipants = null; + pendingMatchmakingMode = null; + } + + private void SendMatchmakingChannelCommand(string payload) + { + string mmName = gameCollection.GetGameChatChannelNameFromIdentifier(localGameID) + "-mm"; + Channel matchmakingChannel = connectionManager.FindChannel(mmName); + + if (matchmakingChannel == null) + { + Logger.Log($"[Matchmaking] WARNING { "QueueCommandDropped" }: { $"reason=missing_channel,payload={payload}" }"); + return; + } + + matchmakingChannel.SendCTCPMessage( + $"{MatchmakingService.ChannelCommandName} {payload}", + MatchmakingQueueMessageType, MatchmakingQueueMessagePriority); + + Logger.Log($"[Matchmaking] { "QueueCommandSent" }: { $"channel={matchmakingChannel.ChannelName}, payload={payload}, messageType={MatchmakingQueueMessageType}, priority={MatchmakingQueueMessagePriority}" }"); + } + + private static bool IsLikelyMatchmakingRoomName(string roomName) + { + if (string.IsNullOrWhiteSpace(roomName)) + return false; + + return roomName.StartsWith("1v1 Match ", StringComparison.OrdinalIgnoreCase) || + roomName.StartsWith("2v2v2v2 Match ", StringComparison.OrdinalIgnoreCase); + } + + private bool ShouldHideMatchmakingRoom(string channelName, string roomName) + { + if (!string.IsNullOrWhiteSpace(channelName) && + hiddenMatchmakingChannels.Contains(channelName)) + { + return true; + } + + return IsLikelyMatchmakingRoomName(roomName); + } + + private void TrackHiddenMatchmakingRoom(string channelName, string roomName, string mode) + { + if (!string.IsNullOrWhiteSpace(channelName)) + hiddenMatchmakingChannels.Add(channelName); + + bool removedAny = false; + for (int i = lbGameList.HostedGames.Count - 1; i >= 0; i--) + { + HostedCnCNetGame hostedGame = (HostedCnCNetGame)lbGameList.HostedGames[i]; + bool channelMatches = !string.IsNullOrWhiteSpace(channelName) && + string.Equals(hostedGame.ChannelName, channelName, StringComparison.OrdinalIgnoreCase); + bool roomMatches = !string.IsNullOrWhiteSpace(roomName) && + string.Equals(hostedGame.RoomName, roomName, StringComparison.OrdinalIgnoreCase); + + if (!channelMatches && !roomMatches) + continue; + + lbGameList.RemoveGame(i); + removedAny = true; + } + + if (removedAny) + SortAndRefreshHostedGames(); + + Logger.Log($"[Matchmaking] { "HiddenRoomTracked" }: { $"mode={mode}, channel={channelName}, room={roomName}" }"); + } + + private void HandleMatchmakingRoomInvitation(string sender, string argumentsString) + { + try + { + if (!CanReceiveInvitationMessagesFrom(sender)) + { + Logger.Log($"[Matchmaking] { "JoinInvitationIgnored" }: { $"reason=sender_not_allowed,sender={sender}" }"); + return; + } + + if (isInGameRoom || gameLobby.Enabled || gameLoadingLobby.Enabled || isJoiningGame || ProgramConstants.IsInGame) + { + Logger.Log($"[Matchmaking] { "JoinInvitationIgnored" }: { $"reason=already_in_room_or_joining,sender={sender}" }"); + return; + } + + string[] arguments = argumentsString.Split(';'); + if (arguments.Length < 8) + return; + + string mode = arguments[0]; + string channelName = arguments[1]; + string roomName = arguments[2]; + string password = arguments[3]; + + if (!int.TryParse(arguments[4], out int maxPlayers)) + return; + + if (!int.TryParse(arguments[5], out int skillLevel)) + skillLevel = 0; + + string tunnelAddress = arguments[6]; + + if (!int.TryParse(arguments[7], out int tunnelPort)) + return; + + TrackHiddenMatchmakingRoom(channelName, roomName, mode); + + Logger.Log($"[Matchmaking] { "InviteReceived" }: { $"sender={sender}, mode={mode}, room={roomName}, channel={channelName}" }"); + + CnCNetTunnel tunnel = FindTunnelByAddressAndPort(tunnelAddress, tunnelPort); + if (tunnel == null) + { + Logger.Log($"[Matchmaking] WARNING { "JoinInvitationRejected" }: { $"reason=tunnel_unavailable,address={tunnelAddress},port={tunnelPort}" }"); + AddMainChannelNotice($"Matchmaking failed: tunnel {tunnelAddress}:{tunnelPort} is unavailable."); + return; + } + + if (localGame == null) + { + Logger.Log($"[Matchmaking] WARNING { "JoinInvitationRejected" }: { "reason=missing_local_game" }"); + AddMainChannelNotice("Matchmaking failed: local game definition is missing."); + return; + } + + if (matchmakingService?.IsInQueue == true) + matchmakingService.LeaveQueue(false, false); + + AddMainChannelNotice($"Match found ({mode}). Joining room..."); + + if (!MatchmakingSettings.Instance.DebugMode) + matchFoundWindow.Show(); + + HostedCnCNetGame hostedGame = new HostedCnCNetGame( + channelName, + ProgramConstants.CNCNET_PROTOCOL_REVISION, + ProgramConstants.GAME_VERSION, + maxPlayers, + roomName, + !string.IsNullOrEmpty(password), + true, + new[] { sender, ProgramConstants.PLAYERNAME }, + sender, + string.Empty, + mode, + string.Empty) + { + Game = localGame, + TunnelServer = tunnel, + SkillLevel = skillLevel, + IsLoadedGame = false, + Locked = false, + Incompatible = false + }; + + Logger.Log($"[Matchmaking] { "JoinInvitationAccepted" }: { $"sender={sender}, mode={mode}, channel={channelName}, room={roomName}" }"); + + Logger.Log($"[Matchmaking] { "JoinGameStarted" }: { $"source=matchmaking_invite, host={sender}, mode={mode}, channel={channelName}, room={roomName}" }"); + + gameLobby.SetMatchmakingMode(mode); + bool joinStarted = JoinGame(hostedGame, password, connectionManager.MainChannel); + Logger.Log($"[Matchmaking] { "JoinGameResult" }: { $"source=matchmaking_invite, host={sender}, mode={mode}, channel={channelName}, room={roomName}, success={joinStarted}" }"); + + if (!joinStarted) + { + matchFoundWindow.Hide(); + gameLobby.SetMatchmakingMode(null); + AddMainChannelNotice("Matchmaking failed: unable to join the created room."); + } + } + catch (Exception ex) + { + Logger.Log($"[Matchmaking] ERROR { "JoinInvitationException" }: { ex }"); + AddMainChannelNotice("Matchmaking failed: unexpected error while joining room."); + } + } + + private CnCNetTunnel FindTunnelByAddressAndPort(string address, int port) => + tunnelHandler.Tunnels?.FirstOrDefault(t => + string.Equals(t.Address, address, StringComparison.OrdinalIgnoreCase) && + t.Port == port); + + private void ResetMatchmakingState() + { + pendingMatchmakingParticipants = null; + pendingMatchmakingMode = null; + matchFoundWindow.Hide(); + matchmakingService?.Reset(); + } private void LbGameList_DoubleLeftClick(object sender, EventArgs e) => JoinSelectedGame(); @@ -963,6 +1593,9 @@ private bool JoinGame(HostedCnCNetGame hg, string password, IMessageView message private void _JoinGame(HostedCnCNetGame hg, string password) { + if (matchmakingService?.IsInQueue == true) + matchmakingService.LeaveQueue(true, false); + connectionManager.MainChannel.AddMessage(new ChatMessage(Color.White, string.Format("Attempting to join game {0} ...".L10N("Client:Main:AttemptJoin"), hg.RoomName))); isJoiningGame = true; @@ -1044,9 +1677,20 @@ private void GameChannel_UserAdded(object sender, Online.ChannelUserEventArgs e) if (e.User.IRCUser.Name == ProgramConstants.PLAYERNAME) { ClearGameChannelEvents(gameChannel); + if (!string.IsNullOrEmpty(pendingMatchmakingMode)) + gameLobby.SetMatchmakingMode(pendingMatchmakingMode); gameLobby.OnJoined(); + + if (!string.IsNullOrEmpty(pendingMatchmakingMode)) + { + bool presetApplied = gameLobby.ApplyMatchmakingHostPreset(pendingMatchmakingMode); + Logger.Log($"[Matchmaking] { "MatchmakingPresetApplied" }: { $"mode={pendingMatchmakingMode}, success={presetApplied}" }"); + } + isInGameRoom = true; SetLogOutButtonText(); + Logger.Log($"[Matchmaking] { "JoinedGameRoom" }: { $"channel={gameChannel.ChannelName}, room={gameChannel.UIName}" }"); + TrySendMatchmakingParticipantsToRoom(ProgramConstants.PLAYERNAME); } } @@ -1074,6 +1718,7 @@ private void BtnNewGame_LeftClick(object sender, EventArgs e) return; } + if (matchmakingService?.IsInQueue == true) { matchmakingService.LeaveQueue(true, false); } gameCreationPanel.Show(); var gcw = (GameCreationWindow)gameCreationPanel.Tag; @@ -1082,6 +1727,9 @@ private void BtnNewGame_LeftClick(object sender, EventArgs e) private void Gcw_GameCreated(object sender, GameCreationEventArgs e) { + if (matchmakingService?.IsInQueue == true) + matchmakingService.LeaveQueue(true, false); + if (gameLobby.Enabled || gameLoadingLobby.Enabled) return; @@ -1107,12 +1755,27 @@ private void Gcw_GameCreated(object sender, GameCreationEventArgs e) gameCreationPanel.Hide(); + lastCreatedGameChannelName = channelName; + lastCreatedGamePassword = password; + lastCreatedGameRoomName = e.GameRoomName; + lastCreatedGameTunnel = e.Tunnel; + lastCreatedGameMaxPlayers = e.MaxPlayers; + lastCreatedGameSkillLevel = e.SkillLevel; + + Logger.Log($"[Matchmaking] { "RoomCreated" }: { $"channel={channelName}, room={e.GameRoomName}, maxPlayers={e.MaxPlayers}, tunnel={e.Tunnel?.Address}:{e.Tunnel?.Port}" }"); + + if (!string.IsNullOrEmpty(pendingMatchmakingMode)) + TrackHiddenMatchmakingRoom(channelName, e.GameRoomName, pendingMatchmakingMode); + // update the friends window so it can enable the Invite option pmWindow.SetInviteChannelInfo(channelName, e.GameRoomName, string.IsNullOrEmpty(e.Password) ? string.Empty : e.Password); } private void Gcw_LoadedGameCreated(object sender, GameCreationEventArgs e) { + if (matchmakingService?.IsInQueue == true) + matchmakingService.LeaveQueue(true, false); + if (gameLobby.Enabled || gameLoadingLobby.Enabled) return; @@ -1206,6 +1869,7 @@ private void DdColor_SelectedIndexChanged(object sender, EventArgs e) private void ConnectionManager_Disconnected(object sender, EventArgs e) { + btnMatchmaking.AllowClick = false; btnNewGame.AllowClick = false; btnJoinGame.AllowClick = false; ddCurrentChannel.AllowDropDown = false; @@ -1216,6 +1880,7 @@ private void ConnectionManager_Disconnected(object sender, EventArgs e) followedGames.Clear(); gameCreationPanel.Hide(); + ResetMatchmakingState(); // Switch channel to default if (localGame != null) @@ -1231,10 +1896,12 @@ private void ConnectionManager_Disconnected(object sender, EventArgs e) private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e) { + btnMatchmaking.AllowClick = true; btnNewGame.AllowClick = true; btnJoinGame.AllowClick = true; ddCurrentChannel.AllowDropDown = true; tbChatInput.Enabled = true; + matchmakingAutoTestStarted = false; Channel cncnetChannel = connectionManager.FindChannel("#cncnet"); cncnetChannel?.Join(); @@ -1242,6 +1909,9 @@ private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e string localGameChatChannelName = gameCollection.GetGameChatChannelNameFromIdentifier(localGameID); connectionManager.FindChannel(localGameChatChannelName).Join(); + string mmName = localGameChatChannelName + "-mm"; + connectionManager.FindChannel(mmName)?.Join(); + string localGameBroadcastChannel = gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGameID); connectionManager.FindChannel(localGameBroadcastChannel).Join(); @@ -1262,6 +1932,9 @@ private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e gameCheckCancellation = new CancellationTokenSource(); CnCNetGameCheck.Instance.InitializeService(gameCheckCancellation); + + if (matchmakingAutoTestEnabled) + Logger.Log($"[Matchmaking] { "AutoTestEnabled" }: { "MM_AUTOTEST=1" }"); } private void ConnectionManager_PrivateCTCPReceived(object sender, PrivateCTCPEventArgs e) @@ -1557,30 +2230,36 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr string msg = e.Message.Substring(5); // Cut out GAME part string[] splitMessage = msg.Split(new char[] { ';' }); - if (splitMessage.Length != 14) + try { - Logger.Log("Ignoring CTCP game message because of an invalid amount of parameters."); + if (splitMessage.Length == 0) + return; - // Remind users that the network is good but the client is outdated or newer - if (lbGameList.Items.Count == 0 && lbGameList.HostedGames.Count == 0 && !ctcpInvalidGameMessageShown) + string revision = splitMessage[0]; + if (revision != ProgramConstants.CNCNET_PROTOCOL_REVISION) + return; + + // R13 game announcements are expected to have at least 13 parameters. + // The 14th (broadcasted game options) is optional. + if (splitMessage.Length < 13) { - ctcpInvalidGameMessageShown = true; + Logger.Log("Ignoring CTCP game message because of an invalid amount of parameters."); - string message = ("There are no games listed but you are indeed connected. The client did receive a game message but can't add it to the list because the message is invalid. " + - "You can ignore this prompt if there are games listed later. " + - "Otherwise, this usually means that your client is outdated, or, in a rare case, newer than others. Please check for updates.").L10N("Client:Main:InvalidGameMessage"); + // Remind users that the network is good but the client is outdated or newer + if (lbGameList.Items.Count == 0 && lbGameList.HostedGames.Count == 0 && !ctcpInvalidGameMessageShown) + { + ctcpInvalidGameMessageShown = true; - lbChatMessages.AddMessage(new ChatMessage(Color.Gray, message)); - } + string message = ("There are no games listed but you are indeed connected. The client did receive a game message but can't add it to the list because the message is invalid. " + + "You can ignore this prompt if there are games listed later. " + + "Otherwise, this usually means that your client is outdated, or, in a rare case, newer than others. Please check for updates.").L10N("Client:Main:InvalidGameMessage"); - return; - } + lbChatMessages.AddMessage(new ChatMessage(Color.Gray, message)); + } - try - { - string revision = splitMessage[0]; - if (revision != ProgramConstants.CNCNET_PROTOCOL_REVISION) return; + } + string gameVersion = splitMessage[1]; int maxPlayers = Conversions.IntFromString(splitMessage[2], 0); string gameRoomChannelName = splitMessage[3]; @@ -1596,6 +2275,9 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr string gameMode = splitMessage[8]; string[] tunnelAddressAndPort = splitMessage[9].Split(':'); + if (tunnelAddressAndPort.Length < 2) + return; + string tunnelAddress = tunnelAddressAndPort[0]; int tunnelPort = int.Parse(tunnelAddressAndPort[1]); @@ -1603,6 +2285,20 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr int skillLevel = int.Parse(splitMessage[11]); string mapHash = splitMessage[12]; + if (ShouldHideMatchmakingRoom(gameRoomChannelName, gameRoomDisplayName)) + { + int hiddenGameIndex = lbGameList.HostedGames.FindIndex(hg => + string.Equals(((HostedCnCNetGame)hg).ChannelName, gameRoomChannelName, StringComparison.OrdinalIgnoreCase)); + if (hiddenGameIndex > -1) + { + lbGameList.RemoveGame(hiddenGameIndex); + SortAndRefreshHostedGames(); + } + + Logger.Log($"[Matchmaking] { "HiddenRoomFiltered" }: { $"host={e.UserName}, channel={gameRoomChannelName}, room={gameRoomDisplayName}" }"); + return; + } + int[] gameOptionValues = null; // Games with different versions may have different option counts, so ignore @@ -1613,7 +2309,7 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr { gameOptionValues = null; } - else if (!string.IsNullOrEmpty(splitMessage[13])) + else if (splitMessage.Length > 13 && !string.IsNullOrEmpty(splitMessage[13])) { gameOptionValues = new int[broadcastableSettings.Count]; string[] allValueStrings = splitMessage[13].Split(','); @@ -1755,6 +2451,9 @@ private void UpdateMessageBox_YesClicked(XNAMessageBox messageBox) => private void BtnLogout_LeftClick(object sender, EventArgs e) { + if (matchmakingService?.IsInQueue == true) + matchmakingService.LeaveQueue(true, false); + if (isInGameRoom) { topBar.SwitchToPrimary(); diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs new file mode 100644 index 000000000..4a021323a --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -0,0 +1,108 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Rampastring.Tools; +using ClientCore; + +namespace DTAClient.DXGUI.Multiplayer.CnCNet +{ + public class MatchmakingMapDefinitions + { + private static MatchmakingMapDefinitions? instance; + + public static MatchmakingMapDefinitions Instance => instance ??= new MatchmakingMapDefinitions(); + + public Dictionary> ModeMapEntries { get; private set; } + + private MatchmakingMapDefinitions() + { + ModeMapEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + public void Initialize() + { + ModeMapEntries.Clear(); + + string iniPath = SafePath.CombineFilePath(ProgramConstants.GamePath, "INI", "MatchmakingMaps.ini"); + var fileInfo = SafePath.GetFile(iniPath); + if (!fileInfo.Exists) + { + Logger.Log($"[Matchmaking] Warning: Configuration file not found at {iniPath}. Map pools will be empty."); + return; + } + + IniFile ini = new IniFile(iniPath); + var sections = ini.GetSections(); + + if (sections == null || sections.Count == 0) + { + Logger.Log($"[Matchmaking] Warning: No sections defined in {iniPath}. Map pools will be empty."); + return; + } + + foreach (string section in sections) + { + var keys = ini.GetSectionKeys(section); + if (keys != null && keys.Count > 0) + { + List entries = new List(); + foreach (string key in keys) + { + string rawValue = ini.GetStringValue(section, key, string.Empty); + if (string.IsNullOrEmpty(rawValue)) + continue; + + // Format: SHA1|TeamA:1,2|TeamB:3,4 + string[] parts = rawValue.Split('|'); + var entry = new MatchmakingMapEntry(parts[0].Trim()); + + for (int i = 1; i < parts.Length; i++) + { + string tag = parts[i].Trim(); + if (tag.StartsWith("Team", StringComparison.OrdinalIgnoreCase)) + { + int colonIndex = tag.IndexOf(':'); + if (colonIndex > 0) + { + string teamName = tag.Substring(0, colonIndex).ToUpper(); + string spawnList = tag.Substring(colonIndex + 1); + + int teamId = teamName switch + { + "TEAMA" => 1, + "TEAMB" => 2, + "TEAMC" => 3, + "TEAMD" => 4, + _ => 0 + }; + + if (teamId > 0) + { + entry.TeamSpawns[teamId] = spawnList.Split(',') + .Select(s => int.TryParse(s.Trim(), out int val) ? val : -1) + .Where(v => v >= 0) + .ToArray(); + } + } + } + else + { + entry.GameMode = tag; + Logger.Log($"[Matchmaking] Detected Game Mode '{entry.GameMode}' for map SHA1 {entry.SHA1}"); + } + } + Logger.Log($"[Matchmaking] Loaded map entry for SHA1 {entry.SHA1} with {entry.TeamSpawns.Count} team mappings and GameMode='{entry.GameMode}' from raw: {rawValue}"); + entries.Add(entry); + } + if (entries.Count > 0) + { + ModeMapEntries[section] = entries; + Logger.Log($"[Matchmaking] Loaded map pool for mode [{section}] with {entries.Count} maps."); + } + } + } + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs new file mode 100644 index 000000000..75ce1c565 --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs @@ -0,0 +1,26 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace DTAClient.DXGUI.Multiplayer.CnCNet +{ + public class MatchmakingMapEntry + { + public string SHA1 { get; set; } = string.Empty; + + /// + /// Map of Team ID (1=A, 2=B, etc.) to list of allowed Spawn Point IDs. + /// + public Dictionary TeamSpawns { get; set; } = new Dictionary(); + + public string? GameMode { get; set; } + + public MatchmakingMapEntry(string sha1) + { + SHA1 = sha1; + } + + public bool HasForcedSpawns => TeamSpawns.Count > 0; + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs new file mode 100644 index 000000000..55e8e5ead --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs @@ -0,0 +1,486 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DTAClient.DXGUI.Multiplayer.CnCNet +{ + internal sealed class MatchmakingService + { + public const string ChannelCommandName = "MMQ"; + public const string PrivateJoinCommandName = "MMJOIN"; + + private const string CommandJoin = "JOIN"; + private const string CommandLeave = "LEAVE"; + private const string CommandMatch = "MATCH"; + + private readonly Random random; + + private readonly Func? selectedModeProvider; + private readonly Func canJoinQueue; + private readonly Func canHostMatch; + private readonly Func requiredPlayersForMode; + private readonly Action sendQueueCommand; + private readonly Action addNotice; + private readonly Action setQueueUiState; + private readonly Action> localMatchClaimedCallback; + private readonly Func? localPlayerNameProvider; + + private readonly Dictionary> queues = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + private readonly HashSet handledMatchIds = + new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet pendingClaimIds = + new HashSet(StringComparer.OrdinalIgnoreCase); + + private bool isInQueue; + private string? queueMode; + private string? queueTicket; + private DateTime lastActionTime = DateTime.MinValue; + private bool isBusy; + private const double ActionCooldownMs = 2000; + + public MatchmakingService( + Random random, + Func? localPlayerNameProvider, + Func? selectedModeProvider, + Func canJoinQueue, + Func canHostMatch, + Func requiredPlayersForMode, + Action sendQueueCommand, + Action addNotice, + Action setQueueUiState, + Action> localMatchClaimedCallback) + { + this.random = random; + this.localPlayerNameProvider = localPlayerNameProvider; + this.selectedModeProvider = selectedModeProvider; + this.canJoinQueue = canJoinQueue; + this.canHostMatch = canHostMatch; + this.requiredPlayersForMode = requiredPlayersForMode; + this.sendQueueCommand = sendQueueCommand; + this.addNotice = addNotice; + this.setQueueUiState = setQueueUiState; + this.localMatchClaimedCallback = localMatchClaimedCallback; + } + + public bool IsInQueue => isInQueue; + + private string LocalPlayerName => localPlayerNameProvider?.Invoke() ?? string.Empty; + + private void Log(string level, string eventName, string? details = null) + { + string localPlayerName = LocalPlayerName; + string formatted = string.IsNullOrEmpty(details) + ? $"[MM][{level}][{localPlayerName}] {eventName}" + : $"[MM][{level}][{localPlayerName}] {eventName} :: {details}"; + Rampastring.Tools.Logger.Log(formatted); + } + + private void Info(string eventName, string? details = null) => Log("INFO", eventName, details); + private void Warn(string eventName, string? details = null) => Log("WARN", eventName, details); + + public void ToggleQueue() + { + if (DateTime.Now.Subtract(lastActionTime).TotalMilliseconds < ActionCooldownMs) + { + Warn("ActionThrottled", $"cooldown_remaining={ActionCooldownMs - DateTime.Now.Subtract(lastActionTime).TotalMilliseconds}ms"); + return; + } + + if (isBusy) + { + Warn("ActionBlocked", "is_busy"); + return; + } + + if (isInQueue) + { + LeaveQueue(true, true); + return; + } + + StartQueue(); + } + + public void StartQueue() + { + if (isInQueue) + return; + + if (DateTime.Now.Subtract(lastActionTime).TotalMilliseconds < ActionCooldownMs) + return; + + if (!canJoinQueue()) + { + addNotice("Cannot join matchmaking queue while already joining or inside a game room."); + Warn("QueueJoinRejected", "client_not_ready"); + return; + } + + string mode = selectedModeProvider?.Invoke() ?? string.Empty; + + if (string.IsNullOrEmpty(mode)) + { + Warn("QueueJoinRejected", "empty_mode"); + return; + } + + string localPlayerName = LocalPlayerName; + + if (string.IsNullOrEmpty(localPlayerName)) + { + Warn("QueueJoinRejected", "empty_local_player_name"); + addNotice("Cannot join matchmaking queue: missing local player name."); + return; + } + + isBusy = true; + lastActionTime = DateTime.Now; + + isInQueue = true; + queueMode = mode; + queueTicket = $"{DateTime.UtcNow.Ticks}-{random.Next(1000, 9999)}"; + setQueueUiState(true); + + AddOrUpdateQueueEntry(localPlayerName, mode, queueTicket); + sendQueueCommand($"{CommandJoin};{mode};{queueTicket}"); + addNotice($"Joined matchmaking queue ({mode})."); + + Info("QueueJoined", $"mode={mode}, ticket={queueTicket}"); + Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + + isBusy = false; + TryClaimMatch(mode); + } + + public void LeaveQueue(bool broadcastLeave, bool showMessage) + { + if (!isInQueue) + return; + + string? mode = queueMode; + + Info("QueueLeaveRequested", $"mode={mode}, broadcastLeave={broadcastLeave}"); + + isBusy = true; + if (broadcastLeave) + lastActionTime = DateTime.Now; + + ClearQueueState(updateUiState: true); + + if (!string.IsNullOrEmpty(mode)) + { + string localPlayerName = LocalPlayerName; + + if (!string.IsNullOrEmpty(localPlayerName)) + RemoveQueueEntryFromMode(localPlayerName, mode); + + if (broadcastLeave) + sendQueueCommand($"{CommandLeave};{mode}"); + } + + if (showMessage) + addNotice("Left matchmaking queue."); + + isBusy = false; + } + + public void Reset() + { + Info("ResetState"); + queues.Clear(); + handledMatchIds.Clear(); + pendingClaimIds.Clear(); + ClearQueueState(updateUiState: true); + } + + public void HandleChannelCommand(string sender, string commandData) + { + if (string.IsNullOrEmpty(commandData)) + return; + + string[] parts = commandData.Split(';'); + + if (parts.Length == 0) + return; + + Info("QueueCommandReceived", $"sender={sender}, payload={commandData}"); + + switch (parts[0]) + { + case CommandJoin: + if (parts.Length >= 3) + HandleQueueJoin(sender, parts[1], parts[2]); + return; + case CommandLeave: + if (parts.Length >= 2) + HandleQueueLeave(sender, parts[1]); + return; + case CommandMatch: + if (parts.Length >= 4) + HandleMatchClaim(sender, parts[1], parts[2], parts[3]); + return; + default: + Warn("QueueCommandIgnored", $"sender={sender}, payload={commandData}"); + return; + } + } + + public void HandleUserLeftOrQuit(string playerName) + { + if (string.IsNullOrEmpty(playerName)) + return; + + Info("UserLeftQueueChannels", $"player={playerName}"); + + RemovePlayerFromAllQueues(playerName); + + foreach (string mode in queues.Keys.ToList()) + { + TryClaimMatch(mode); + } + } + + private void HandleQueueJoin(string sender, string mode, string ticket) + { + if (string.IsNullOrEmpty(mode) || string.IsNullOrEmpty(ticket)) + return; + + AddOrUpdateQueueEntry(sender, mode, ticket); + + Info("QueueJoinApplied", $"sender={sender}, mode={mode}, ticket={ticket}"); + Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + + TryClaimMatch(mode); + } + + private void HandleQueueLeave(string sender, string mode) + { + if (string.IsNullOrEmpty(mode)) + return; + + RemoveQueueEntryFromMode(sender, mode); + + Info("QueueLeaveApplied", $"sender={sender}, mode={mode}"); + Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + + if (string.Equals(sender, LocalPlayerName, StringComparison.OrdinalIgnoreCase)) + ClearQueueState(updateUiState: true); + + TryClaimMatch(mode); + } + + private void HandleMatchClaim(string sender, string mode, string matchId, string playerList) + { + if (string.IsNullOrEmpty(mode) || string.IsNullOrEmpty(matchId) || string.IsNullOrEmpty(playerList)) + return; + + pendingClaimIds.Remove(matchId); + + if (!handledMatchIds.Add(matchId)) + { + Info("MatchClaimIgnored", $"reason=already_handled, matchId={matchId}"); + return; + } + + List participants = playerList + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (participants.Count != requiredPlayersForMode(mode)) + { + Warn("MatchClaimIgnored", $"reason=invalid_count, matchId={matchId}, mode={mode}, participants={participants.Count}"); + return; + } + + participants = participants + .OrderBy(p => GetDeterministicHash(matchId + p)) + .ToList(); + + if (!participants.Contains(sender, StringComparer.OrdinalIgnoreCase)) + { + Warn("MatchClaimIgnored", $"reason=sender_not_in_match, sender={sender}, matchId={matchId}"); + return; + } + + string designatedHost = participants[0]; + + Info("MatchClaimAccepted", $"matchId={matchId}, mode={mode}, sender={sender}, host={designatedHost}, participants={string.Join(",", participants)}"); + + RemovePlayersFromQueues(participants); + + bool localPlayerInMatch = participants.Any(p => + string.Equals(p, LocalPlayerName, StringComparison.OrdinalIgnoreCase)); + + if (localPlayerInMatch) + ClearQueueState(updateUiState: true); + + if (string.Equals(LocalPlayerName, designatedHost, StringComparison.OrdinalIgnoreCase)) + { + localMatchClaimedCallback(mode, participants); + } + else if (localPlayerInMatch) + { + Info("MatchClaimAwaitingHost", $"matchId={matchId}, mode={mode}, expectedHost={designatedHost}"); + } + + TryClaimMatch(mode); + } + + private void TryClaimMatch(string mode) + { + if (string.IsNullOrEmpty(mode)) + return; + + if (!queues.TryGetValue(mode, out List? queue)) + { + Info("MatchClaimSkipped", $"mode={mode}, reason=queue_missing"); + return; + } + + int requiredPlayers = requiredPlayersForMode(mode); + + if (requiredPlayers <= 0) + { + Warn("MatchClaimSkipped", $"mode={mode}, reason=invalid_required_players, count={requiredPlayers}"); + return; + } + + if (queue == null || queue.Count < requiredPlayers) + { + Info("MatchClaimWaiting", $"mode={mode}, queued={queue?.Count ?? 0}, required={requiredPlayers}"); + return; + } + + List participants = queue + .OrderBy(qe => GetDeterministicHash(qe.Ticket + qe.PlayerName)) + .Take(requiredPlayers) + .ToList(); + + string localPlayerName = LocalPlayerName; + + if (!participants.Any(p => string.Equals(p.PlayerName, localPlayerName, StringComparison.OrdinalIgnoreCase))) + { + Info("MatchClaimSkipped", $"mode={mode}, reason=local_not_in_participants, local={localPlayerName}, participants={string.Join(",", participants.Select(p => p.PlayerName))}"); + return; + } + + if (!canHostMatch()) + { + Info("MatchClaimSkipped", $"mode={mode}, reason=cannot_host_now, local={localPlayerName}"); + return; + } + + string matchId = $"{mode}:{string.Join("|", participants.Select(p => p.PlayerName + ":" + p.Ticket))}"; + + if (handledMatchIds.Contains(matchId) || pendingClaimIds.Contains(matchId)) + { + Info("MatchClaimSkipped", $"mode={mode}, reason=already_pending_or_handled, matchId={matchId}"); + return; + } + + pendingClaimIds.Add(matchId); + + string participantList = string.Join(",", participants.Select(p => p.PlayerName)); + + Info("MatchClaimBroadcast", $"matchId={matchId}, mode={mode}, sender={localPlayerName}, participants={participantList}"); + + sendQueueCommand($"{CommandMatch};{mode};{matchId};{participantList}"); + + // Process local claim immediately so host creation doesn't depend on IRC echo behavior. + HandleMatchClaim(localPlayerName, mode, matchId, participantList); + } + + private string GetQueueSnapshot(string mode) + { + if (string.IsNullOrEmpty(mode) || !queues.TryGetValue(mode, out List? queue) || queue == null || queue.Count == 0) + return "(empty)"; + + return string.Join(",", queue + .OrderBy(q => q.PlayerName, StringComparer.OrdinalIgnoreCase) + .Select(q => $"{q.PlayerName}:{q.Ticket}")); + } + + private void AddOrUpdateQueueEntry(string playerName, string mode, string ticket) + { + if (string.IsNullOrEmpty(playerName) || string.IsNullOrEmpty(mode) || string.IsNullOrEmpty(ticket)) + return; + + RemovePlayerFromAllQueues(playerName); + + if (!queues.TryGetValue(mode, out List? queue) || queue == null) + { + queue = new List(); + queues[mode] = queue; + } + + queue.Add(new QueueEntry + { + PlayerName = playerName, + Ticket = ticket + }); + } + + private void RemoveQueueEntryFromMode(string playerName, string mode) + { + if (!queues.TryGetValue(mode, out List? queue) || queue == null) + return; + + queue.RemoveAll(qe => string.Equals(qe.PlayerName, playerName, StringComparison.OrdinalIgnoreCase)); + + if (queue.Count == 0) + queues.Remove(mode); + } + + private void RemovePlayerFromAllQueues(string playerName) + { + if (string.IsNullOrEmpty(playerName)) + return; + + foreach (string mode in queues.Keys.ToList()) + { + RemoveQueueEntryFromMode(playerName, mode); + } + } + + private void RemovePlayersFromQueues(List playerNames) + { + foreach (string playerName in playerNames) + { + RemovePlayerFromAllQueues(playerName); + } + } + + private void ClearQueueState(bool updateUiState) + { + isInQueue = false; + queueMode = null; + queueTicket = null; + + if (updateUiState) + setQueueUiState(false); + } + + private int GetDeterministicHash(string input) + { + unchecked + { + long hash = 23; + foreach (char c in input) + { + hash = (hash * 31) + c; + } + return (int)hash; + } + } + + private sealed class QueueEntry + { + public string PlayerName { get; set; } = string.Empty; + public string Ticket { get; set; } = string.Empty; + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs new file mode 100644 index 000000000..427d8f04b --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs @@ -0,0 +1,118 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Rampastring.Tools; +using ClientCore; + +namespace DTAClient.DXGUI.Multiplayer.CnCNet +{ + public class MatchmakingModeDefinition + { + public string UIName { get; set; } = string.Empty; + public int PlayerCount { get; set; } + public string[] AlliedSideNames { get; set; } = Array.Empty(); + public string[] SovietSideNames { get; set; } = Array.Empty(); + public string[] AlliedColors { get; set; } = Array.Empty(); + public string[] SovietColors { get; set; } = Array.Empty(); + public Dictionary ForceCheckboxes { get; set; } = new Dictionary(); + public Dictionary ForceDropdowns { get; set; } = new Dictionary(); + public bool AssignTeams { get; set; } + } + + public class MatchmakingSettings + { + private static MatchmakingSettings? instance; + + public static MatchmakingSettings Instance => instance ??= new MatchmakingSettings(); + + public bool DebugMode => false; + + public List Modes { get; private set; } + + private MatchmakingSettings() + { + Modes = new List(); + } + + public void Initialize() + { + Modes.Clear(); + + string iniPath = SafePath.CombineFilePath(ProgramConstants.GamePath, "INI", "Matchmaking.ini"); + var fileInfo = SafePath.GetFile(iniPath); + if (!fileInfo.Exists) + { + Logger.Log($"[Matchmaking] Warning: Configuration file not found at {iniPath}. Matchmaking modes will be empty."); + return; + } + + IniFile ini = new IniFile(iniPath); + // DebugMode is now a hardcoded constant above. + Logger.Log($"[Matchmaking] Settings initialization: DebugMode is {(DebugMode ? "ENABLED" : "DISABLED")} (Hardcoded)"); + + List modeKeys = ini.GetSectionKeys("MatchmakingModes"); + + if (modeKeys == null || modeKeys.Count == 0) + { + Logger.Log($"[Matchmaking] Warning: No modes defined in [MatchmakingModes] section of {iniPath}."); + return; + } + + foreach (string key in modeKeys) + { + string modeSection = ini.GetStringValue("MatchmakingModes", key, string.Empty); + if (string.IsNullOrEmpty(modeSection) || !ini.SectionExists(modeSection)) + { + Logger.Log($"[Matchmaking] Warning: Mode section [{modeSection}] is empty or completely missing. Skipping."); + continue; + } + + var mode = new MatchmakingModeDefinition(); + mode.UIName = ini.GetStringValue(modeSection, "UIName", string.Empty); + mode.PlayerCount = ini.GetIntValue(modeSection, "PlayerCount", 2); + mode.AssignTeams = ini.GetBooleanValue(modeSection, "AssignTeams", false); + + string alliedSides = ini.GetStringValue(modeSection, "AlliedSideNames", string.Empty); + mode.AlliedSideNames = string.IsNullOrEmpty(alliedSides) ? Array.Empty() : alliedSides.Split(',').Select(s => s.Trim()).ToArray(); + + string sovietSides = ini.GetStringValue(modeSection, "SovietSideNames", string.Empty); + mode.SovietSideNames = string.IsNullOrEmpty(sovietSides) ? Array.Empty() : sovietSides.Split(',').Select(s => s.Trim()).ToArray(); + + string alliedColors = ini.GetStringValue(modeSection, "AlliedColors", string.Empty); + mode.AlliedColors = string.IsNullOrEmpty(alliedColors) ? Array.Empty() : alliedColors.Split(',').Select(s => s.Trim()).ToArray(); + + string sovietColors = ini.GetStringValue(modeSection, "SovietColors", string.Empty); + mode.SovietColors = string.IsNullOrEmpty(sovietColors) ? Array.Empty() : sovietColors.Split(',').Select(s => s.Trim()).ToArray(); + + // Read Checkboxes + string cbSection = modeSection + "_ForceCheckboxes"; + if (ini.SectionExists(cbSection)) + { + var cbKeys = ini.GetSectionKeys(cbSection); + if (cbKeys != null) + { + foreach (var cbKey in cbKeys) + mode.ForceCheckboxes[cbKey] = ini.GetBooleanValue(cbSection, cbKey, false); + } + } + + // Read Dropdowns + string ddSection = modeSection + "_ForceDropdowns"; + if (ini.SectionExists(ddSection)) + { + var ddKeys = ini.GetSectionKeys(ddSection); + if (ddKeys != null) + { + foreach (var ddKey in ddKeys) + mode.ForceDropdowns[ddKey] = ini.GetStringValue(ddSection, ddKey, string.Empty); + } + } + + Modes.Add(mode); + Logger.Log($"[Matchmaking] Loaded mode: {mode.UIName} ({mode.PlayerCount} players)."); + } + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index dfe8b4001..895b321a3 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -19,6 +19,7 @@ using System.Text; using DTAClient.Domain.Multiplayer.CnCNet; using ClientCore.Extensions; +using System.Threading.Tasks; namespace DTAClient.DXGUI.Multiplayer.GameLobby { @@ -41,6 +42,12 @@ public class CnCNetGameLobby : MultiplayerGameLobby private const string DICE_ROLL_MESSAGE = "DR"; private const string CHANGE_TUNNEL_SERVER_MESSAGE = "CHTNL"; + private static readonly string[] MATCHMAKING_1V1_MODE_PRIORITY = { "Default", "Standard", "Tournament", "Custom Map" }; + private static readonly string[] MATCHMAKING_1V1_COLOR_PRIORITY = { "Blue", "Red" }; + private static readonly string[] MATCHMAKING_2V2V2V2_COLOR_PRIORITY = { "Blue", "Red", "Green", "Orange", "Cyan", "Purple", "Yellow", "Pink" }; + private static readonly string[] MATCHMAKING_ALLIED_SIDE_NAMES = { "Allied", "Allies" }; + private static readonly string[] MATCHMAKING_SOVIET_SIDE_NAMES = { "Soviet", "Soviets" }; + public CnCNetGameLobby( WindowManager windowManager, TopBar topBar, @@ -147,6 +154,10 @@ Random random private int skillLevel = ClientConfiguration.Instance.DefaultSkillLevelIndex; private string gameRoomName; + private string matchmakingPresetMode; + private bool autoLaunchStarted; + private bool factionPresetApplied; + private System.Threading.CancellationTokenSource autoLaunchCancellation; private bool isCustomPassword = false; @@ -302,7 +313,7 @@ public void SetUp(Channel channel, bool isHost, int playerLimit, public void StartInactiveCheck() { - if (isCustomPassword) + if (isCustomPassword || IsHiddenMatchmakingRoom()) return; gameHostInactiveChecker?.Start(); @@ -338,10 +349,32 @@ public void OnJoined() channel.SendCTCPMessage("FHSH " + gameFilesHash, QueuedMessageType.SYSTEM_MESSAGE, 10); } - TopBar.AddPrimarySwitchable(this); - TopBar.SwitchToPrimary(); - WindowManager.SelectedControl = tbChatInput; + if (!IsHiddenMatchmakingRoom() || MatchmakingSettings.Instance.DebugMode) + { + TopBar.AddPrimarySwitchable(this); + TopBar.SwitchToPrimary(); + WindowManager.SelectedControl = tbChatInput; + } ResetAutoReadyCheckbox(); + + if (!IsHost && IsHiddenMatchmakingRoom()) + { + // Set opposite of Host Faction/Color + DTAClient.Domain.Multiplayer.PlayerInfo hostInfo = Players.Find(p => !p.IsAI && p.Name != ProgramConstants.PLAYERNAME); + if (hostInfo != null) + { + int oppositeSide = hostInfo.SideId == 0 ? 1 : 0; + int oppositeColor = hostInfo.ColorId == 0 ? 1 : 0; + RequestPlayerOptions(oppositeSide, oppositeColor, 0, 0); + } + + if (!MatchmakingSettings.Instance.DebugMode) + { + chkAutoReady.Enable(); + chkAutoReady.Checked = true; + } + } + UpdatePing(); UpdateDiscordPresence(true); } @@ -571,6 +604,11 @@ public void ChangeChatColor(IRCColor chatColor) public override void Clear() { base.Clear(); + factionPresetApplied = false; // Reset for subsequent matchmaking matches + matchmakingPresetMode = null; // Clear the matchmaking state to prevent bleeding into normal games + + autoLaunchCancellation?.Cancel(); + autoLaunchStarted = false; if (channel != null) { @@ -624,7 +662,7 @@ public void LeaveGameLobby() } Clear(); - channel?.Leave(); + try { channel?.Leave(); } catch { } } private void ConnectionManager_Disconnected(object sender, EventArgs e) => HandleConnectionLoss(); @@ -673,30 +711,34 @@ protected override void UpdateDiscordPresence(bool resetTimer = false) private void Channel_UserQuitIRC(object sender, UserNameEventArgs e) { - RemovePlayer(e.UserName); + AddCallback(new Action(() => { + RemovePlayer(e.UserName); - if (e.UserName == hostName) - { - connectionManager.MainChannel.AddMessage(new ChatMessage( - ERROR_MESSAGE_COLOR, "The game host abandoned the game.".L10N("Client:Main:HostAbandoned"))); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); - } - else - UpdateDiscordPresence(); + if (e.UserName == hostName) + { + connectionManager.MainChannel.AddMessage(new ChatMessage( + ERROR_MESSAGE_COLOR, "The game host abandoned the game.".L10N("Client:Main:HostAbandoned"))); + BtnLeaveGame_LeftClick(this, EventArgs.Empty); + } + else + UpdateDiscordPresence(); + }), null); } private void Channel_UserLeft(object sender, UserNameEventArgs e) { - RemovePlayer(e.UserName); + AddCallback(new Action(() => { + RemovePlayer(e.UserName); - if (e.UserName == hostName) - { - connectionManager.MainChannel.AddMessage(new ChatMessage( - ERROR_MESSAGE_COLOR, "The game host abandoned the game.".L10N("Client:Main:HostAbandoned"))); - BtnLeaveGame_LeftClick(this, EventArgs.Empty); - } - else - UpdateDiscordPresence(); + if (e.UserName == hostName) + { + connectionManager.MainChannel.AddMessage(new ChatMessage( + ERROR_MESSAGE_COLOR, "The game host abandoned the game.".L10N("Client:Main:HostAbandoned"))); + BtnLeaveGame_LeftClick(this, EventArgs.Empty); + } + else + UpdateDiscordPresence(); + }), null); } private void Channel_UserKicked(object sender, UserNameEventArgs e) @@ -761,6 +803,14 @@ private void Channel_UserAdded(object sender, ChannelUserEventArgs e) // new player, and it also sends an options broadcast message //CopyPlayerDataToUI(); This is also called by ChangeMap() ChangeMap(GameModeMap); + + if (matchmakingPresetMode != null) + { + // broadcastChanges: true ensures that when a new player joins, + // the host sends them their assigned team and color immediately. + ApplyMatchmakingFactionColorPreset(broadcastChanges: true); + } + BroadcastPlayerOptions(); BroadcastPlayerExtraOptions(); UpdateDiscordPresence(); @@ -872,9 +922,8 @@ protected override void HostLaunchGame() StringBuilder sb = new StringBuilder("START "); sb.Append(UniqueGameID); - for (int pId = 0; pId < Players.Count; pId++) - { - Players[pId].Port = playerPorts[pId]; + for (int pId = 0; pId < Players.Count; pId++) { + Players[pId].Port = playerPorts[pId]; sb.Append(";"); sb.Append(Players[pId].Name); sb.Append(";"); @@ -1469,6 +1518,19 @@ protected override void HandleMapUpdated(Map updatedMap, string previousSHA1) protected override void GameProcessExited() { ResetGameState(); + + if (!string.IsNullOrEmpty(matchmakingPresetMode) && !MatchmakingSettings.Instance.DebugMode) + { + Logger.Log($"MatchmakingGameExited: Auto-leaving room. mode={matchmakingPresetMode}"); + LeaveGameLobby(); + return; + } + + if (IsHiddenMatchmakingRoom()) + { + TopBar.AddPrimarySwitchable(this); + TopBar.SwitchToPrimary(); + } } protected void GameStartAborted() @@ -1479,6 +1541,9 @@ protected void GameStartAborted() protected void ResetGameState() { base.GameProcessExited(); + autoLaunchCancellation?.Cancel(); + autoLaunchStarted = false; + factionPresetApplied = false; channel.SendCTCPMessage("RETURN", QueuedMessageType.SYSTEM_MESSAGE, 20); ReturnNotification(ProgramConstants.PLAYERNAME); @@ -1589,6 +1654,21 @@ protected override void StartGame() channel.SendCTCPMessage("STRTD", QueuedMessageType.SYSTEM_MESSAGE, 20); base.StartGame(); + + if (IsHiddenMatchmakingRoom()) + { + _ = Task.Run(async () => + { + await Task.Delay(3000); + if (!string.IsNullOrEmpty(matchmakingPresetMode)) + { + Logger.Log("Matchmaking: Game started, explicitly leaving the lobby like pressing Game Lobby button."); + WindowManager.AddCallback(new Action(() => { + LeaveGameLobby(); + }), null); + } + }); + } } protected override void WriteSpawnIniAdditions(IniFile iniFile) @@ -1601,6 +1681,33 @@ protected override void WriteSpawnIniAdditions(IniFile iniFile) iniFile.SetIntValue("Settings", "GameID", UniqueGameID); iniFile.SetBooleanValue("Settings", "Host", IsHost); + if (PlayerExtraOptionsPanel != null) + { + Logger.Log($"[AutoAlly] UI Checkbox UseTeamStartMappings: {PlayerExtraOptionsPanel.UseTeamStartMappings}"); + if (PlayerExtraOptionsPanel.UseTeamStartMappings) + { + iniFile.SetBooleanValue("Settings", "IsUseTeamStartMappings", true); + var mappings = PlayerExtraOptionsPanel.GetTeamStartMappings(); + Logger.Log($"[AutoAlly] Writing {mappings.Count} mappings to [TeamStartMappings]."); + foreach (var mapping in mappings) + { + if (mapping.IsValid) + { + Logger.Log($"[AutoAlly] Mapping: Start={mapping.Start}, TeamId={mapping.TeamId}"); + iniFile.SetIntValue("TeamStartMappings", mapping.Start.ToString(), mapping.TeamId); + } + else + { + Logger.Log($"[AutoAlly] Warning: Mapping is invalid! Team={mapping.Team}, Start={mapping.Start}"); + } + } + } + } + else + { + Logger.Log("[AutoAlly] Error: PlayerExtraOptionsPanel is null!"); + } + PlayerInfo localPlayer = FindLocalPlayer(); if (localPlayer == null) @@ -1893,7 +2000,62 @@ private void HandleTunnelServerChange(CnCNetTunnel tunnel) protected override bool UpdateLaunchGameButtonStatus() { - btnLaunchGame.Enabled = base.UpdateLaunchGameButtonStatus() && !tunnelErrorMode; + bool isReady = base.UpdateLaunchGameButtonStatus() && !tunnelErrorMode; + btnLaunchGame.Enabled = isReady; + + if (IsHost && IsHiddenMatchmakingRoom()) + { + // Re-apply faction/color preset once the guest has joined (Players.Count >= 2) + if (!factionPresetApplied && Players.Count >= 2) + { + factionPresetApplied = true; + Logger.Log("[MM] Applying faction/color preset now that all players have joined."); + ApplyMatchmakingFactionColorPreset(broadcastChanges: true); + } + + if (isReady && !autoLaunchStarted && !MatchmakingSettings.Instance.DebugMode) + { + autoLaunchStarted = true; + // // StartAutoLaunchCountdown(); // Disabled temporarily by user request // Disabled temporarily by user request + autoLaunchCancellation = new System.Threading.CancellationTokenSource(); + var token = autoLaunchCancellation.Token; + + Logger.Log("Starting matchmaking auto-launch loop..."); + System.Threading.Tasks.Task.Run(async () => { + try { + await System.Threading.Tasks.Task.Delay(5000, token); + while (!token.IsCancellationRequested) + { + WindowManager.AddCallback(new Action(() => { + if (IsHiddenMatchmakingRoom() && !ProgramConstants.IsInGame) + { + if (!Locked) + { + Logger.Log("Auto-Locking game room."); + LockGame(); + } + else + { + Logger.Log("Auto-Launch attempt."); + BtnLaunchGame_LeftClick(this, EventArgs.Empty); + } + } + }), null); + await System.Threading.Tasks.Task.Delay(3000, token); + } + } catch (System.Threading.Tasks.TaskCanceledException) { + Logger.Log("Auto-Launch loop cancelled."); + } + }); + } + else if (!isReady && autoLaunchStarted) + { + autoLaunchCancellation?.Cancel(); + autoLaunchStarted = false; + Logger.Log("Auto-Launch cancelled due to players status change."); + } + } + return btnLaunchGame.Enabled; } @@ -2228,6 +2390,9 @@ private void AccelerateGameBroadcasting() => private void BroadcastGame() { + if (channel == null || connectionManager == null || gameCollection == null || tunnelHandler == null) + return; + Channel broadcastChannel = connectionManager.FindChannel(gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGame)); if (broadcastChannel == null) @@ -2268,7 +2433,10 @@ private void BroadcastGame() sb.Append(";"); sb.Append(GameMode?.UntranslatedUIName ?? string.Empty); sb.Append(";"); - sb.Append(tunnelHandler.CurrentTunnel.Address + ":" + tunnelHandler.CurrentTunnel.Port); + if (tunnelHandler.CurrentTunnel != null) + sb.Append(tunnelHandler.CurrentTunnel.Address + ":" + tunnelHandler.CurrentTunnel.Port); + else + sb.Append("0.0.0.0:0"); sb.Append(";"); sb.Append(0); // LoadedGameId sb.Append(";"); @@ -2315,5 +2483,459 @@ private void BroadcastGame() #endregion public override string GetSwitchName() => "Game Lobby".L10N("Client:Main:GameLobby"); - } + + public void SetMatchmakingMode(string mode) + { + matchmakingPresetMode = string.IsNullOrWhiteSpace(mode) ? null : mode.Trim(); + } + + public bool IsHiddenMatchmakingRoom() => !string.IsNullOrEmpty(matchmakingPresetMode); + + public bool ApplyMatchmakingHostPreset(string mode) + { + if (!IsHost || string.IsNullOrEmpty(mode)) + return false; + + matchmakingPresetMode = mode; + ApplyMatchmakingOptionPreset(mode); + ApplyMatchmakingFactionColorPreset(broadcastChanges: true); + return true; + } + + public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) + { + if (!IsHost || string.IsNullOrEmpty(matchmakingPresetMode) || Players.Count == 0) + return false; + + var modeDef = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, matchmakingPresetMode, StringComparison.OrdinalIgnoreCase)); + if (modeDef == null) + return false; + + int alliedSideIndex = FindSideIndex(modeDef.AlliedSideNames); + int sovietSideIndex = FindSideIndex(modeDef.SovietSideNames); + + if (alliedSideIndex < 0 || sovietSideIndex < 0) + { + Logger.Log($"[MM] PresetSkipped: mode={matchmakingPresetMode}, reason=side-not-found, alliedSideIndex={alliedSideIndex}, sovietSideIndex={sovietSideIndex}"); + return false; + } + + bool canAssignTeams = modeDef.AssignTeams && + GameModeMap != null && + !GameModeMap.IsCoop && + !GameModeMap.ForceNoTeams && + !GetPlayerExtraOptions().IsForceNoTeams; + + int playerCountToAssign = Math.Min(Players.Count, modeDef.PlayerCount); + int maximumTeamId = ProgramConstants.TEAMS.Count; + int teamSize = modeDef.PlayerCount / 2; // For 3v3, it will be 3. For 2v2, it will be 2. + bool anyChanged = false; + var usedColorIndices = new HashSet(); + + MatchmakingMapEntry? mapEntry = null; + if (MatchmakingMapDefinitions.Instance.ModeMapEntries.TryGetValue(matchmakingPresetMode, out var entries)) + { + mapEntry = entries.FirstOrDefault(e => string.Equals(e.SHA1, GameModeMap.Map.SHA1, StringComparison.OrdinalIgnoreCase)); + } + + Logger.Log($"[Matchmaking] Applying preset logic: Mode={matchmakingPresetMode}, MapSHA1={GameModeMap?.Map?.SHA1}, EntryFound={mapEntry != null}, IsHost={IsHost}"); + + if (mapEntry != null && !string.IsNullOrEmpty(mapEntry.GameMode) && IsHost) + { + int modeIndex = ddGameModeMapFilter.Items.FindIndex(item => string.Equals(item.Text?.Trim(), mapEntry.GameMode, StringComparison.OrdinalIgnoreCase)); + if (modeIndex >= 0 && ddGameModeMapFilter.SelectedIndex != modeIndex) + { + Logger.Log($"[Matchmaking] Switching Game Mode to '{mapEntry.GameMode}' for map {mapEntry.SHA1}"); + ddGameModeMapFilter.SelectedIndex = modeIndex; + + // After switching Game Mode, we must re-select the map as the list was refreshed + int mapIndex = -1; + for (int i = 0; i < lbGameModeMapList.ItemCount; i++) + { + var item = lbGameModeMapList.GetItem(1, i); + if (item != null && item.Tag is GameModeMap gmm && string.Equals(gmm.Map?.SHA1, mapEntry.SHA1, StringComparison.OrdinalIgnoreCase)) + { + mapIndex = i; + break; + } + } + + if (mapIndex >= 0) + { + lbGameModeMapList.SelectedIndex = mapIndex; + } + else + { + Logger.Log($"[Matchmaking] Warning: Could not re-select map {mapEntry.SHA1} after switching to mode {mapEntry.GameMode}"); + } + } + } + + if (mapEntry != null && mapEntry.HasForcedSpawns && IsHost) + { + var pExtraOptions = GetPlayerExtraOptions(); + pExtraOptions.IsUseTeamStartMappings = true; + pExtraOptions.TeamStartMappings.Clear(); + + // Build a complete mapping list for all possible spawns + // Waypoints in the INI are 0-based. Waypoint N maps to Start N+1 in CnCNet. + int maxWP = mapEntry.TeamSpawns.Values.SelectMany(x => x).DefaultIfEmpty(-1).Max(); + int spawnCount = Math.Max(maxWP, 7) + 1; // Ensure we cover at least 8 spots + + Logger.Log($"[Matchmaking] Building Auto-Allying table for {spawnCount} spots based on map entry."); + + for (int wpIndex = 0; wpIndex < spawnCount; wpIndex++) + { + string teamChar = TeamStartMapping.NO_TEAM; + int spawnNumber = wpIndex + 1; // Spawns in INI are 1-indexed (1, 2, 3...) + + foreach (var kvp in mapEntry.TeamSpawns) + { + if (kvp.Value.Contains(spawnNumber)) + { + // teamId 1 -> A, 2 -> B, etc. + if (kvp.Key >= 1 && kvp.Key <= ProgramConstants.TEAMS.Count) + teamChar = ProgramConstants.TEAMS[kvp.Key - 1]; + break; + } + } + + pExtraOptions.TeamStartMappings.Add(new TeamStartMapping() { Start = spawnNumber, Team = teamChar }); + Logger.Log($"[Matchmaking] Map: WP {wpIndex} -> Spawn {spawnNumber} -> Team {teamChar}"); + } + + SetPlayerExtraOptions(pExtraOptions); + BroadcastPlayerExtraOptions(); + Logger.Log("[Matchmaking] Auto-Allying table applied and broadcasted."); + } + else if (mapEntry == null && IsHost) + { + Logger.Log($"[Matchmaking] Warning: No map entry found in MatchmakingMaps.ini for SHA1 {GameModeMap?.Map?.SHA1}. Auto-Allying skipped."); + } + + var teamPlayerCounts = new Dictionary(); + + for (int i = 0; i < playerCountToAssign; i++) + { + PlayerInfo playerInfo = Players[i]; + + int sideIndex = i % 2 == 0 ? alliedSideIndex : sovietSideIndex; + string[] preferredColorNames = i % 2 == 0 ? modeDef.AlliedColors : modeDef.SovietColors; + int colorIndex = ResolveColorIndex(preferredColorNames, i, usedColorIndices); + int teamId = 0; + + if (canAssignTeams && maximumTeamId > 0 && teamSize > 0) + { + teamId = Math.Min((i / teamSize) + 1, maximumTeamId); + } + + bool playerChanged = false; + if (playerInfo.SideId != sideIndex) + { + playerInfo.SideId = sideIndex; + playerChanged = true; + } + + if (colorIndex >= 0 && playerInfo.ColorId != colorIndex) + { + playerInfo.ColorId = colorIndex; + playerChanged = true; + } + + // TeamId is now handled automatically by Auto-Allying (IsUseTeamStartMappings) + // if mapEntry is present. We only need to calculate it for logging or if mismatch. + int assignedTeamId = teamId; + + if (mapEntry != null && mapEntry.TeamSpawns.TryGetValue(teamId, out int[]? spawns)) + { + if (!teamPlayerCounts.ContainsKey(teamId)) + teamPlayerCounts[teamId] = 0; + + int playerInTeamIndex = teamPlayerCounts[teamId]++; + if (playerInTeamIndex < spawns.Length) + { + int spawnPoint = spawns[playerInTeamIndex]; + if (playerInfo.StartingLocation != spawnPoint) + { + playerInfo.StartingLocation = spawnPoint; + playerChanged = true; + } + } + } + + if (playerChanged) + anyChanged = true; + + Logger.Log($"[MM] PlayerPreset: mode={matchmakingPresetMode}, player={playerInfo.Name}, slot={i}, sideId={playerInfo.SideId}, colorId={playerInfo.ColorId}, teamId={playerInfo.TeamId}"); + } + + if (!anyChanged) + return false; + + CopyPlayerDataToUI(); + + if (broadcastChanges) + { + BroadcastPlayerOptions(); + BroadcastPlayerExtraOptions(); + } + + return true; + } + + private void ApplyMatchmakingOptionPreset(string mode) + { + ResetGameOptionsToDefaults(); + MatchmakingModeDefinition def = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, mode, StringComparison.OrdinalIgnoreCase)); + + if (def == null) + { + Logger.Log($"[Matchmaking] Error: Matchmaking mode '{mode}' not found in settings."); + return; + } + + Logger.Log($"[Matchmaking] Applying option preset for mode '{mode}'."); + + foreach (KeyValuePair kvp in def.ForceCheckboxes) + { + Logger.Log($"[Matchmaking] Forcing Checkbox '{kvp.Key}' to {kvp.Value}"); + SetCheckBoxValue(kvp.Key, kvp.Value); + } + + foreach (KeyValuePair kvp in def.ForceDropdowns) + { + Logger.Log($"[Matchmaking] Forcing Dropdown '{kvp.Key}' to '{kvp.Value}'"); + // Prioritize matching by Text (e.g. "10000") over Index + GameLobbyDropDown dd = FindDropDown(kvp.Key); + if (dd == null) + { + Logger.Log($"[Matchmaking] Warning: Dropdown '{kvp.Key}' not found."); + continue; + } + + int textIndex = dd.Items.FindIndex(item => string.Equals(item.Text?.Trim(), kvp.Value, StringComparison.OrdinalIgnoreCase)); + if (textIndex >= 0) + { + SetDropDownValueByIndex(kvp.Key, textIndex); + } + else if (int.TryParse(kvp.Value, out int idx)) + { + SetDropDownValueByIndex(kvp.Key, idx); + } + else + { + Logger.Log($"[Matchmaking] Warning: Could not find value '{kvp.Value}' for dropdown '{kvp.Key}'"); + } + } + + // Matchmaking Random Map Selection (Strictly Backend Filtered) + if (GameModeMaps != null && GameModeMaps.Count > 0) + { + // Reload map definitions from INI to catch any manual changes without restarting + MatchmakingMapDefinitions.Instance.Initialize(); + + int reqPlayers = def.PlayerCount; + var suitableMaps = new List(); + + Logger.Log($"[Matchmaking] Processing map selection for mode '{mode}' (req players: {reqPlayers})."); + + // 1. Try to find maps from the defined list in MatchmakingMaps.ini + var definedMapHashes = new List(); + if (MatchmakingMapDefinitions.Instance.ModeMapEntries.TryGetValue(mode, out var definedMapEntries) && definedMapEntries.Count > 0) + { + definedMapHashes = definedMapEntries.Select(e => e.SHA1).ToList(); + Logger.Log($"[Matchmaking] Looking for maps in defined INI list by Hash: {string.Join(", ", definedMapHashes)}"); + + foreach (GameModeMap m in GameModeMaps) + { + if (m.Map == null || string.IsNullOrEmpty(m.Map.SHA1)) + continue; + + // Exact hash match + bool hashMatched = definedMapHashes.Any(hash => string.Equals(m.Map.SHA1, hash.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (hashMatched) + { + if (m.Map.MaxPlayers == reqPlayers) + { + Logger.Log($"[Matchmaking] Map OK: '{m.Map.Name}' (file: {m.Map.BaseFilePath}) matches INI hash and player count ({m.Map.MaxPlayers})."); + suitableMaps.Add(m); + } + else + { + Logger.Log($"[Matchmaking] Map REJECTED: '{m.Map.Name}' (file: {m.Map.BaseFilePath}) matches INI hash but has {m.Map.MaxPlayers} players (Mode needs {reqPlayers}). skipping."); + } + } + } + } + else + { + Logger.Log($"[Matchmaking] No map hashes defined in MatchmakingMaps.ini for mode '{mode}'."); + } + + // 2. Fallback if no matching defined maps were found + if (suitableMaps.Count == 0) + { + Logger.Log($"[Matchmaking] Warning: No suitable maps from INI found for {mode}. (Target hashes were: {string.Join(", ", definedMapHashes ?? new List())})"); + + // Show a few available maps in log for debugging + List sampleMaps = GameModeMaps.Where(m => m.Map != null && m.Map.MaxPlayers == reqPlayers).Take(3).Select(m => $"'{m.Map.Name}'").ToList(); + Logger.Log($"[Matchmaking] Debug: Available maps for {reqPlayers} players include: {string.Join(", ", sampleMaps)}..."); + + suitableMaps = GameModeMaps.Where(m => m.Map != null && m.Map.MaxPlayers == reqPlayers).ToList(); + } + + if (suitableMaps.Count > 0) + { + Random rnd = new Random(); + GameModeMap randomMap = suitableMaps[rnd.Next(suitableMaps.Count)]; + ChangeMap(randomMap); + + // Force the UI to highlight this map in the listbox + RefreshMapSelectionUI(); + + Logger.Log($"[Matchmaking] Map applied: '{randomMap.Map.Name}' (MaxPlayers: {randomMap.Map.MaxPlayers}). UI Refreshed."); + } + else + { + Logger.Log($"[Matchmaking] Error: NO matching maps from INI found for mode '{mode}' with {reqPlayers} players. (Checked: {suitableMaps.Count} maps)"); + } + } + } + + private string StripMapPrefix(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + int idx = name.IndexOf(']'); + + if (idx != -1 && idx < 6) // Handles "[2] ", "[8] ", etc. + return name.Substring(idx + 1).Trim(); + + return name.Trim(); + } + + private void SetCheckBoxValue(string checkBoxName, bool value) + + { + GameLobbyCheckBox checkBox = CheckBoxes.Find(cb => + string.Equals(cb.Name, checkBoxName, StringComparison.OrdinalIgnoreCase)); + if (checkBox == null) + { + Logger.Log($"[Matchmaking] Warning: Checkbox '{checkBoxName}' not found in lobby."); + return; + } + + checkBox.HostChecked = value; + checkBox.UserChecked = value; + + if (checkBox.Checked != value) + checkBox.Checked = value; + } + + private GameLobbyDropDown FindDropDown(string dropDownName) => + DropDowns.Find(dd => string.Equals(dd.Name, dropDownName, StringComparison.OrdinalIgnoreCase)); + + private void SetDropDownValueByText(string dropDownName, string text) + { + if (string.IsNullOrEmpty(text)) + return; + + GameLobbyDropDown dropDown = FindDropDown(dropDownName); + if (dropDown == null) + return; + + int index = dropDown.Items.FindIndex(item => + string.Equals(item.Text?.Trim(), text, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + SetDropDownValueByIndex(dropDownName, index); + } + + private void SetDropDownValueByIndex(string dropDownName, int index) + { + GameLobbyDropDown dropDown = FindDropDown(dropDownName); + if (dropDown == null) + { + Logger.Log($"[Matchmaking] Warning: Dropdown '{dropDownName}' not found in lobby."); + return; + } + + if (index < 0 || index >= dropDown.Items.Count) + return; + + dropDown.HostSelectedIndex = index; + dropDown.UserSelectedIndex = index; + + if (dropDown.SelectedIndex != index) + dropDown.SelectedIndex = index; + } + + private int FindSideIndex(IEnumerable sideNames) + { + XNAClientDropDown sideDropDown = ddPlayerSides?.FirstOrDefault(dd => dd != null && dd.Items != null && dd.Items.Count > 0); + if (sideDropDown == null || sideNames == null) + return -1; + + foreach (string sideName in sideNames) + { + if (string.IsNullOrWhiteSpace(sideName)) continue; + + int index = sideDropDown.Items.FindIndex(item => + string.Equals(item.Tag as string, sideName, StringComparison.OrdinalIgnoreCase) || + string.Equals(item.Text?.Trim(), sideName, StringComparison.OrdinalIgnoreCase)); + + if (index >= 0 && sideDropDown.Items[index].Selectable) + return index; + } + return -1; + } + + private int FindColorIndex(string colorName) + { + if (string.IsNullOrWhiteSpace(colorName)) return -1; + + XNAClientColorDropDown colorDropDown = ddPlayerColors?.FirstOrDefault(dd => dd != null && dd.Items != null && dd.Items.Count > 0); + if (colorDropDown == null) return -1; + + return colorDropDown.Items.FindIndex(item => string.Equals(item.Text?.Trim(), colorName, StringComparison.OrdinalIgnoreCase)); + } + + private int ResolveColorIndex(string[] preferredColorNames, int playerIndex, HashSet usedColorIndices) + { + if (usedColorIndices == null) throw new ArgumentNullException(nameof(usedColorIndices)); + + int preferredColorIndex = -1; + if (preferredColorNames != null && playerIndex >= 0 && playerIndex < preferredColorNames.Length) + preferredColorIndex = FindColorIndex(preferredColorNames[playerIndex]); + + if (IsColorSelectable(preferredColorIndex) && !usedColorIndices.Contains(preferredColorIndex)) + { + usedColorIndices.Add(preferredColorIndex); + return preferredColorIndex; + } + + XNAClientColorDropDown colorDropDown = ddPlayerColors?.FirstOrDefault(dd => dd != null && dd.Items != null && dd.Items.Count > 0); + if (colorDropDown == null) return -1; + + for (int i = 1; i < colorDropDown.Items.Count; i++) + { + if (!usedColorIndices.Contains(i) && IsColorSelectable(i)) + { + usedColorIndices.Add(i); + return i; + } + } + return preferredColorIndex; + } + + private bool IsColorSelectable(int colorIndex) + { + XNAClientColorDropDown colorDropDown = ddPlayerColors?.FirstOrDefault(dd => dd != null && dd.Items != null && dd.Items.Count > 0); + if (colorDropDown == null || colorIndex < 0 || colorIndex >= colorDropDown.Items.Count) + return false; + + return colorDropDown.Items[colorIndex].Selectable; + } +} } diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs index c6fc7eccd..751ffde7a 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs @@ -1252,7 +1252,11 @@ protected PlayerInfo GetPlayerInfoForIndex(int playerIndex) protected PlayerExtraOptions GetPlayerExtraOptions() => PlayerExtraOptionsPanel == null ? new PlayerExtraOptions() : PlayerExtraOptionsPanel.GetPlayerExtraOptions(); - protected void SetPlayerExtraOptions(PlayerExtraOptions playerExtraOptions) => PlayerExtraOptionsPanel?.SetPlayerExtraOptions(playerExtraOptions); + protected void SetPlayerExtraOptions(PlayerExtraOptions playerExtraOptions) + { + Logger.Log($"[AutoAlly] SetPlayerExtraOptions called. IsUseTeamStartMappings={playerExtraOptions.IsUseTeamStartMappings}, MappingsCount={playerExtraOptions.TeamStartMappings?.Count ?? 0}"); + PlayerExtraOptionsPanel?.SetPlayerExtraOptions(playerExtraOptions); + } protected string GetTeamMappingsError() => GetPlayerExtraOptions()?.GetTeamMappingsError(); @@ -1329,6 +1333,22 @@ protected GameModeMapFilter GetDefaultGameModeMapFilter() return ddGameModeMapFilter.Items[GetDefaultGameModeMapFilterIndex()].Tag as GameModeMapFilter; } + /// + /// Resets all game options to their default values as defined in the INI. + /// + protected void ResetGameOptionsToDefaults() + { + Logger.Log("[Matchmaking] Resetting all game options to defaults."); + foreach (var cb in CheckBoxes) + ReadINIForControl(cb); + + foreach (var dd in DropDowns) + ReadINIForControl(dd); + + if (PlayerExtraOptionsPanel != null) + ReadINIForControl(PlayerExtraOptionsPanel); + } + private int GetSpectatorSideIndex() => SideCount + RandomSelectorCount; ///