From 410080f2b13e6d5906cede361bd8799065e16caf Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Fri, 20 Mar 2026 19:58:57 +0100 Subject: [PATCH 01/16] Add custom matchmaking support - Add `btnMatchmaking` toggle button in CnCNet lobby with state tracking. - Implement Queue System: * Users enter queue via MatchmakingService socket server for specific mode. * Server triggers MatchFound event specifying channel, host and player list. * Ensures fully automated room creation for Host and joint transitions for Guest. - Support Multiple Matchmaking Modes: * 1v1: Strictly filters 2-player maps with preset fair 1v1 starting options. * 2v2/2v2v2v2: Dynamic options configured by server setups supporting groups. - General Fixes: * Fix LeaveGameLobby NullReference inside BroadcastGame triggers. * Disable instant instant triggers/countdown on creation setup. * strict backend Map MaxPlayers filter logic during room creations. --- .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 656 +++++++++++++++++- .../Multiplayer/CnCNet/MatchmakingLogger.cs | 34 + .../Multiplayer/CnCNet/MatchmakingService.cs | 402 +++++++++++ .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 423 ++++++++++- 4 files changed, 1472 insertions(+), 43 deletions(-) create mode 100644 DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs create mode 100644 DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 45a664015..2553dae0a 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; @@ -55,7 +55,8 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, 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; @@ -73,6 +74,7 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private GlobalContextMenu globalContextMenu; private XNAClientButton btnLogout; + private XNAClientButton btnMatchmaking; private XNAClientButton btnNewGame; private XNAClientButton btnJoinGame; @@ -91,6 +93,7 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private XNAClientStateButton btnGameSortAlpha; private XNAClientToggleButton btnGameFilterOptions; + private XNAClientDropDown ddMatchmakingMode; private DarkeningPanel gameCreationPanel; @@ -146,9 +149,35 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private bool ctcpNoTunnelMessageShown = false; private bool ctcpNoTunnelForGamesMessageShown = false; + private MatchmakingService matchmakingService; + private MatchmakingLogger matchmakingLogger; + 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) @@ -167,10 +196,19 @@ public override void Initialize() BackgroundTexture = AssetLoader.LoadTexture("cncnetlobbybg.png"); localGameID = ClientConfiguration.Instance.LocalGame; localGame = gameCollection.GameList.Find(g => g.InternalName.ToUpper() == localGameID.ToUpper()); + matchmakingLogger = new MatchmakingLogger(() => ProgramConstants.PLAYERNAME); + + 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 +237,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 +380,34 @@ 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); + ddMatchmakingMode.AddItem(new XNADropDownItem { Text = "1v1" }); + ddMatchmakingMode.AddItem(new XNADropDownItem { Text = "2v2v2v2" }); + ddMatchmakingMode.SelectedIndex = 0; + ddMatchmakingMode.AllowDropDown = true; + + matchmakingService = new MatchmakingService( + random, + () => ProgramConstants.PLAYERNAME, + matchmakingLogger, + GetSelectedMatchmakingMode, + CanJoinMatchmakingQueue, + CanHostMatchmakingQueue, + GetRequiredPlayersForMode, + SendMatchmakingChannelCommand, + AddMainChannelNotice, + SetMatchmakingQueueUiState, + OnLocalMatchClaimed); + InitializeGameList(); + AddChild(btnMatchmaking); AddChild(btnNewGame); AddChild(btnJoinGame); AddChild(btnLogout); @@ -359,6 +424,7 @@ public override void Initialize() AddChild(lblOnline); AddChild(lblOnlineCount); AddChild(tbGameSearch); + AddChild(ddMatchmakingMode); AddChild(btnGameSortAlpha); AddChild(btnGameFilterOptions); @@ -371,6 +437,8 @@ public override void Initialize() pmWindow.SetJoinUserAction(JoinUser); base.Initialize(); + AlignMatchmakingButtonWithMainButtons(); + LayoutTopLobbyControls(); WindowManager.CenterControlOnScreen(this); @@ -534,6 +602,15 @@ private void InitializeGameList() item.Tag = chatChannel; + if (game.InternalName.ToUpper() == localGameID.ToUpper()) + { + chatChannel.CTCPReceived += MatchmakingChatChannel_CTCPReceived; + chatChannel.UserAdded += MatchmakingChatChannel_UserAdded; + chatChannel.UserLeft += MatchmakingChatChannel_UserLeftOrQuit; + chatChannel.UserQuitIRC += MatchmakingChatChannel_UserLeftOrQuit; + chatChannel.UserKicked += MatchmakingChatChannel_UserLeftOrQuit; + } + if (!string.IsNullOrEmpty(game.GameBroadcastChannel)) { var gameBroadcastChannel = connectionManager.FindChannel(game.GameBroadcastChannel); @@ -822,7 +899,475 @@ 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 (gameLobby.Enabled) + { + gameLobby.LeaveGameLobby(); + isInGameRoom = false; // Fix race condition for immediate queue toggle + } + if (gameLoadingLobby.Enabled) + { + gameLoadingLobby.Clear(); + isInGameRoom = false; + } + + matchmakingService?.ToggleQueue(); + } + + private string GetSelectedMatchmakingMode() => + ddMatchmakingMode.SelectedItem?.Text ?? "1v1"; + + private int GetRequiredPlayersForMode(string mode) => + string.Equals(mode, "2v2v2v2", StringComparison.OrdinalIgnoreCase) ? 8 : 2; + + private bool CanJoinMatchmakingQueue() + { + if (isInGameRoom || gameLobby.Enabled || gameLoadingLobby.Enabled || isJoiningGame || ProgramConstants.IsInGame) + { + matchmakingLogger?.Info("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) + return; + + int y = ddColor.Y; + + ddCurrentChannel.ClientRectangle = new Rectangle( + lbChatMessages.Right - CurrentChannelWidth, + y, + CurrentChannelWidth, + ddCurrentChannel.Height); + + lblCurrentChannel.ClientRectangle = new Rectangle( + ddCurrentChannel.X - CurrentChannelLabelWidth, + ddCurrentChannel.Y + 2, + 0, + 0); + + int modeX = lblCurrentChannel.X - TopControlsSpacing - MatchmakingModeWidth; + int minimumModeX = btnGameFilterOptions != null + ? btnGameFilterOptions.Right + TopControlsSpacing + : lbGameList.X; + modeX = Math.Max(minimumModeX, modeX); + + ddMatchmakingMode.ClientRectangle = new Rectangle( + modeX, + y, + MatchmakingModeWidth, + UIDesignConstants.BUTTON_HEIGHT); + } + + 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; + matchmakingLogger?.Info("AutoTestJoinQueue", "mode=1v1"); + matchmakingService?.ToggleQueue(); + } + else + { + matchmakingLogger?.Warn("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) + { + matchmakingLogger?.Warn("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; + + matchmakingLogger?.Info("CreateRoomRequested", + $"mode={mode}, maxPlayers={maxPlayers}, participants={string.Join(",", participants)}"); + + string previousCreatedChannelName = lastCreatedGameChannelName; + Gcw_GameCreated(this, new GameCreationEventArgs(roomName, maxPlayers, string.Empty, selectedTunnel, 0)); + + if (pendingMatchmakingParticipants != null && + string.Equals(previousCreatedChannelName, lastCreatedGameChannelName, StringComparison.OrdinalIgnoreCase)) + { + pendingMatchmakingParticipants = null; + pendingMatchmakingMode = null; + matchmakingLogger?.Warn("CreateRoomFailed", "reason=no_new_channel_created"); + AddMainChannelNotice("Matchmaking failed: unable to create a room."); + return; + } + } + catch (Exception ex) + { + matchmakingLogger?.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}"; + + matchmakingLogger?.Info("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)) + { + matchmakingLogger?.Info("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)); + + matchmakingLogger?.Info("InviteSent", + $"mode={pendingMatchmakingMode}, host={hostName}, participant={participant}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, messageType={MatchmakingInviteMessageType}, priority={MatchmakingInviteMessagePriority}"); + } + + matchmakingLogger?.Info("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) + { + Channel matchmakingChannel = connectionManager.FindChannel( + gameCollection.GetGameChatChannelNameFromIdentifier(localGameID)); + + if (matchmakingChannel == null) + { + matchmakingLogger?.Warn("QueueCommandDropped", $"reason=missing_channel,payload={payload}"); + return; + } + + matchmakingChannel.SendCTCPMessage( + $"{MatchmakingService.ChannelCommandName} {payload}", + MatchmakingQueueMessageType, MatchmakingQueueMessagePriority); + + matchmakingLogger?.Info("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--) + { + var 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(); + + matchmakingLogger?.Info("HiddenRoomTracked", + $"mode={mode}, channel={channelName}, room={roomName}"); + } + + private void HandleMatchmakingRoomInvitation(string sender, string argumentsString) + { + try + { + if (!CanReceiveInvitationMessagesFrom(sender)) + { + matchmakingLogger?.Info("JoinInvitationIgnored", $"reason=sender_not_allowed,sender={sender}"); + return; + } + + if (isInGameRoom || gameLobby.Enabled || gameLoadingLobby.Enabled || isJoiningGame || ProgramConstants.IsInGame) + { + matchmakingLogger?.Info("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); + + matchmakingLogger?.Info("InviteReceived", + $"sender={sender}, mode={mode}, room={roomName}, channel={channelName}"); + + CnCNetTunnel tunnel = FindTunnelByAddressAndPort(tunnelAddress, tunnelPort); + if (tunnel == null) + { + matchmakingLogger?.Warn("JoinInvitationRejected", $"reason=tunnel_unavailable,address={tunnelAddress},port={tunnelPort}"); + AddMainChannelNotice($"Matchmaking failed: tunnel {tunnelAddress}:{tunnelPort} is unavailable."); + return; + } + + if (localGame == null) + { + matchmakingLogger?.Warn("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..."); + + var 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 + }; + + matchmakingLogger?.Info("JoinInvitationAccepted", + $"sender={sender}, mode={mode}, channel={channelName}, room={roomName}"); + + matchmakingLogger?.Info("JoinGameStarted", + $"source=matchmaking_invite, host={sender}, mode={mode}, channel={channelName}, room={roomName}"); + + gameLobby.SetMatchmakingMode(mode); + bool joinStarted = JoinGame(hostedGame, password, connectionManager.MainChannel); + matchmakingLogger?.Info("JoinGameResult", + $"source=matchmaking_invite, host={sender}, mode={mode}, channel={channelName}, room={roomName}, success={joinStarted}"); + + if (!joinStarted) + { + gameLobby.SetMatchmakingMode(null); + AddMainChannelNotice("Matchmaking failed: unable to join the created room."); + } + } + catch (Exception ex) + { + matchmakingLogger?.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; + matchmakingService?.Reset(); + } private void LbGameList_DoubleLeftClick(object sender, EventArgs e) => JoinSelectedGame(); @@ -963,6 +1508,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 +1592,21 @@ 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); + matchmakingLogger?.Info("MatchmakingPresetApplied", + $"mode={pendingMatchmakingMode}, success={presetApplied}"); + } + isInGameRoom = true; SetLogOutButtonText(); + matchmakingLogger?.Info("JoinedGameRoom", $"channel={gameChannel.ChannelName}, room={gameChannel.UIName}"); + TrySendMatchmakingParticipantsToRoom(ProgramConstants.PLAYERNAME); } } @@ -1074,6 +1634,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 +1643,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 +1671,28 @@ 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; + + matchmakingLogger?.Info("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 +1786,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 +1797,7 @@ private void ConnectionManager_Disconnected(object sender, EventArgs e) followedGames.Clear(); gameCreationPanel.Hide(); + ResetMatchmakingState(); // Switch channel to default if (localGame != null) @@ -1231,10 +1813,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(); @@ -1262,6 +1846,9 @@ private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e gameCheckCancellation = new CancellationTokenSource(); CnCNetGameCheck.Instance.InitializeService(gameCheckCancellation); + + if (matchmakingAutoTestEnabled) + matchmakingLogger?.Info("AutoTestEnabled", "MM_AUTOTEST=1"); } private void ConnectionManager_PrivateCTCPReceived(object sender, PrivateCTCPEventArgs e) @@ -1557,30 +2144,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; + + string revision = splitMessage[0]; + if (revision != ProgramConstants.CNCNET_PROTOCOL_REVISION) + 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) + // 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 +2189,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 +2199,21 @@ 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(); + } + + matchmakingLogger?.Info("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 +2224,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 +2366,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/MatchmakingLogger.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs new file mode 100644 index 000000000..720baebd7 --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs @@ -0,0 +1,34 @@ +using Rampastring.Tools; +using System; + +namespace DTAClient.DXGUI.Multiplayer.CnCNet +{ + internal sealed class MatchmakingLogger + { + private readonly Func localPlayerNameProvider; + + public MatchmakingLogger(Func localPlayerNameProvider) + { + this.localPlayerNameProvider = localPlayerNameProvider; + } + + public void Info(string eventName, string details = null) => + Logger.Log(Format("INFO", eventName, details)); + + public void Warn(string eventName, string details = null) => + Logger.Log(Format("WARN", eventName, details)); + + public void Error(string eventName, Exception ex, string details = null) => + Logger.Log(Format("ERROR", eventName, $"{details} :: {ex}")); + + private string Format(string level, string eventName, string details) + { + string localPlayerName = localPlayerNameProvider?.Invoke() ?? string.Empty; + + if (string.IsNullOrEmpty(details)) + return $"[MM][{level}][{localPlayerName}] {eventName}"; + + return $"[MM][{level}][{localPlayerName}] {eventName} :: {details}"; + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs new file mode 100644 index 000000000..79cfac28d --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs @@ -0,0 +1,402 @@ +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 MatchmakingLogger logger; + + 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; + + public MatchmakingService( + Random random, + Func localPlayerNameProvider, + MatchmakingLogger logger, + Func selectedModeProvider, + Func canJoinQueue, + Func canHostMatch, + Func requiredPlayersForMode, + Action sendQueueCommand, + Action addNotice, + Action setQueueUiState, + Action> localMatchClaimedCallback) + { + this.random = random; + this.localPlayerNameProvider = localPlayerNameProvider; + this.logger = logger; + 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; + + public void ToggleQueue() + { + if (isInQueue) + { + LeaveQueue(true, true); + return; + } + + StartQueue(); + } + + public void LeaveQueue(bool broadcastLeave, bool showMessage) + { + if (!isInQueue) + return; + + string mode = queueMode; + + logger.Info("QueueLeaveRequested", $"mode={mode}, broadcastLeave={broadcastLeave}"); + + 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."); + } + + public void Reset() + { + logger.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; + + logger.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: + logger.Warn("QueueCommandIgnored", $"sender={sender}, payload={commandData}"); + return; + } + } + + public void HandleUserLeftOrQuit(string playerName) + { + if (string.IsNullOrEmpty(playerName)) + return; + + logger.Info("UserLeftQueueChannels", $"player={playerName}"); + + RemovePlayerFromAllQueues(playerName); + + foreach (var mode in queues.Keys.ToList()) + TryClaimMatch(mode); + } + + private void StartQueue() + { + if (!canJoinQueue()) + { + addNotice("Cannot join matchmaking queue while already joining or inside a game room."); + logger.Warn("QueueJoinRejected", "client_not_ready"); + return; + } + + string mode = selectedModeProvider?.Invoke() ?? string.Empty; + if (string.IsNullOrEmpty(mode)) + { + logger.Warn("QueueJoinRejected", "empty_mode"); + return; + } + + string localPlayerName = LocalPlayerName; + if (string.IsNullOrEmpty(localPlayerName)) + { + logger.Warn("QueueJoinRejected", "empty_local_player_name"); + addNotice("Cannot join matchmaking queue: missing local player name."); + return; + } + + 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})."); + logger.Info("QueueJoined", $"mode={mode}, ticket={queueTicket}"); + logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + + TryClaimMatch(mode); + } + + private void HandleQueueJoin(string sender, string mode, string ticket) + { + if (string.IsNullOrEmpty(mode) || string.IsNullOrEmpty(ticket)) + return; + + AddOrUpdateQueueEntry(sender, mode, ticket); + logger.Info("QueueJoinApplied", $"sender={sender}, mode={mode}, ticket={ticket}"); + logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + + TryClaimMatch(mode); + } + + private void HandleQueueLeave(string sender, string mode) + { + if (string.IsNullOrEmpty(mode)) + return; + + RemoveQueueEntryFromMode(sender, mode); + logger.Info("QueueLeaveApplied", $"sender={sender}, mode={mode}"); + logger.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)) + { + logger.Info("MatchClaimIgnored", $"reason=already_handled, matchId={matchId}"); + return; + } + + List participants = playerList + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (participants.Count != requiredPlayersForMode(mode)) + { + logger.Warn("MatchClaimIgnored", $"reason=invalid_count, matchId={matchId}, mode={mode}, participants={participants.Count}"); + return; + } + + participants = participants + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (!participants.Contains(sender, StringComparer.OrdinalIgnoreCase)) + { + logger.Warn("MatchClaimIgnored", $"reason=sender_not_in_match, sender={sender}, matchId={matchId}"); + return; + } + + string designatedHost = participants[0]; + logger.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) + logger.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)) + { + logger.Info("MatchClaimSkipped", $"mode={mode}, reason=queue_missing"); + return; + } + + int requiredPlayers = requiredPlayersForMode(mode); + if (queue.Count < requiredPlayers) + { + logger.Info("MatchClaimWaiting", $"mode={mode}, queued={queue.Count}, required={requiredPlayers}"); + return; + } + + var participants = queue + .OrderBy(qe => qe.PlayerName, StringComparer.OrdinalIgnoreCase) + .Take(requiredPlayers) + .ToList(); + + string localPlayerName = LocalPlayerName; + if (!participants.Any(p => string.Equals(p.PlayerName, localPlayerName, StringComparison.OrdinalIgnoreCase))) + { + logger.Info("MatchClaimSkipped", $"mode={mode}, reason=local_not_in_participants, local={localPlayerName}, participants={string.Join(",", participants.Select(p => p.PlayerName))}"); + return; + } + + if (!canHostMatch()) + { + logger.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)) + { + logger.Info("MatchClaimSkipped", $"mode={mode}, reason=already_pending_or_handled, matchId={matchId}"); + return; + } + + pendingClaimIds.Add(matchId); + + string participantList = string.Join(",", participants.Select(p => p.PlayerName)); + logger.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.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 = 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)) + 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 sealed class QueueEntry + { + public string PlayerName { get; set; } + public string Ticket { get; set; } + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index dfe8b4001..7031675ca 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -41,6 +41,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 +153,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 +312,7 @@ public void SetUp(Channel channel, bool isHost, int playerLimit, public void StartInactiveCheck() { - if (isCustomPassword) + if (isCustomPassword || IsHiddenMatchmakingRoom()) return; gameHostInactiveChecker?.Start(); @@ -342,6 +352,22 @@ public void OnJoined() 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); + } + + chkAutoReady.Enable(); + chkAutoReady.Checked = true; + } + UpdatePing(); UpdateDiscordPresence(true); } @@ -571,6 +597,10 @@ public void ChangeChatColor(IRCColor chatColor) public override void Clear() { base.Clear(); + factionPresetApplied = false; // Reset for subsequent matchmaking matches + + autoLaunchCancellation?.Cancel(); + autoLaunchStarted = false; if (channel != null) { @@ -624,7 +654,7 @@ public void LeaveGameLobby() } Clear(); - channel?.Leave(); + try { channel?.Leave(); } catch { } } private void ConnectionManager_Disconnected(object sender, EventArgs e) => HandleConnectionLoss(); @@ -673,30 +703,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 +795,12 @@ 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) + { + ApplyMatchmakingFactionColorPreset(broadcastChanges: false); + } + BroadcastPlayerOptions(); BroadcastPlayerExtraOptions(); UpdateDiscordPresence(); @@ -1468,7 +1508,20 @@ protected override void HandleMapUpdated(Map updatedMap, string previousSHA1) /// protected override void GameProcessExited() { + if (!string.IsNullOrEmpty(matchmakingPresetMode)) + { + Logger.Log($"MatchmakingGameExited: Auto-leaving room. mode={matchmakingPresetMode}"); + LeaveGameLobby(); + return; + } + ResetGameState(); + + if (IsHiddenMatchmakingRoom()) + { + TopBar.AddPrimarySwitchable(this); + TopBar.SwitchToPrimary(); + } } protected void GameStartAborted() @@ -1479,6 +1532,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 +1645,11 @@ protected override void StartGame() channel.SendCTCPMessage("STRTD", QueuedMessageType.SYSTEM_MESSAGE, 20); base.StartGame(); + + if (IsHiddenMatchmakingRoom()) + { + TopBar.AddPrimarySwitchable(this); + } } protected override void WriteSpawnIniAdditions(IniFile iniFile) @@ -1893,7 +1954,50 @@ 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) + { + autoLaunchStarted = true; + // // StartAutoLaunchCountdown(); // Disabled temporarily by user request // Disabled temporarily by user request + autoLaunchCancellation = new System.Threading.CancellationTokenSource(); + var token = autoLaunchCancellation.Token; + + Logger.Log("Auto-Launching matchmaking game in 10 seconds..."); + /* + System.Threading.Tasks.Task.Run(async () => { + try { + await System.Threading.Tasks.Task.Delay(10000, token); + WindowManager.AddCallback(new Action(() => { + Logger.Log("Auto-Launching matchmaking game now!"); + HostLaunchGame(); + }), null); + } catch (System.Threading.Tasks.TaskCanceledException) { + Logger.Log("Auto-Launch countdown cancelled."); + } + }); + */ + Logger.Log("Auto-Launch disabled by user requested comment."); + } + else if (!isReady && autoLaunchStarted) + { + autoLaunchCancellation?.Cancel(); + autoLaunchStarted = false; + Logger.Log("Auto-Launch cancelled due to players status change."); + } + } + return btnLaunchGame.Enabled; } @@ -2228,6 +2332,9 @@ private void AccelerateGameBroadcasting() => private void BroadcastGame() { + if (channel == null || connectionManager == null) + return; + Channel broadcastChannel = connectionManager.FindChannel(gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGame)); if (broadcastChannel == null) @@ -2315,5 +2422,277 @@ 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; + + bool is1v1Mode = string.Equals(matchmakingPresetMode, "1v1", StringComparison.OrdinalIgnoreCase); + bool is2v2v2v2Mode = string.Equals(matchmakingPresetMode, "2v2v2v2", StringComparison.OrdinalIgnoreCase); + if (!is1v1Mode && !is2v2v2v2Mode) + return false; + + int alliedSideIndex = FindSideIndex(MATCHMAKING_ALLIED_SIDE_NAMES); + int sovietSideIndex = FindSideIndex(MATCHMAKING_SOVIET_SIDE_NAMES); + if (alliedSideIndex < 0 || sovietSideIndex < 0) + { + Logger.Log($"[MM] PresetSkipped: mode={matchmakingPresetMode}, reason=side-not-found, alliedSideIndex={alliedSideIndex}, sovietSideIndex={sovietSideIndex}"); + return false; + } + + bool canAssignTeams = is2v2v2v2Mode && + GameModeMap != null && + !GameModeMap.IsCoop && + !GameModeMap.ForceNoTeams && + !GetPlayerExtraOptions().IsForceNoTeams; + + string[] preferredColorNames = is1v1Mode + ? MATCHMAKING_1V1_COLOR_PRIORITY + : MATCHMAKING_2V2V2V2_COLOR_PRIORITY; + + int playerCountToAssign = Math.Min(Players.Count, is2v2v2v2Mode ? 8 : 2); + int maximumTeamId = ProgramConstants.TEAMS.Count; + bool anyChanged = false; + var usedColorIndices = new HashSet(); + + for (int i = 0; i < playerCountToAssign; i++) + { + PlayerInfo playerInfo = Players[i]; + + int sideIndex = i % 2 == 0 ? alliedSideIndex : sovietSideIndex; + int colorIndex = ResolveColorIndex(preferredColorNames, i, usedColorIndices); + int teamId = 0; + + if (canAssignTeams && maximumTeamId > 0) + teamId = Math.Min((i / 2) + 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; + } + + if (playerInfo.TeamId != teamId) + { + playerInfo.TeamId = teamId; + 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) + { + + + SetCheckBoxValue("chkShortGame", true); + SetCheckBoxValue("chkRedeplMCV", true); + SetCheckBoxValue("chkAutoRepair", false); + SetCheckBoxValue("chkMultiEng", false); + SetCheckBoxValue("chkIngameAllying", true); + SetCheckBoxValue("chkDestrBridges", true); + SetCheckBoxValue("chkBuildOffAlly", true); + SetCheckBoxValue("chkCrates", false); + SetCheckBoxValue("chkDisableGameSpeed", true); + + // User requested presets + SetCheckBoxValue("chkNoYuri", true); + SetCheckBoxValue("chkSuperWeapons", false); + SetCheckBoxValue("chkIngameAllying", true); + SetCheckBoxValue("chkDestrBridges", true); + SetCheckBoxValue("chkBuildOffAlly", true); + SetCheckBoxValue("chkCrates", false); + SetCheckBoxValue("chkDisableGameSpeed", true); + + SetDropDownValueByText("cmbCredits", "10000"); + SetDropDownValueByText("cmbStartingUnits", "0"); + SetDropDownValueByIndex("cmbGameSpeedCapMultiplayer", 0); + + // Super weapons check + GameLobbyDropDown superWeaponsDropDown = FindDropDown("cmbSuperWeaponsModifier"); + if (superWeaponsDropDown != null) + + + SetDropDownValueByIndex(superWeaponsDropDown.Name, superWeaponsDropDown.Items.Count - 1); + + // Matchmaking Random Map Selection (Strictly Backend Filtered) + if (GameModeMaps != null && GameModeMaps.Count > 0) + { + int reqPlayers = string.Equals(mode, "2v2v2v2", StringComparison.OrdinalIgnoreCase) ? 8 : 2; + var suitableMaps = GameModeMaps.Where(m => m.Map != null && m.Map.MaxPlayers == reqPlayers).ToList(); + + if (suitableMaps.Count > 0) + { + Random rnd = new Random(); + var randomMap = suitableMaps[rnd.Next(suitableMaps.Count)]; + ChangeMap(randomMap); + Logger.Log($"[Matchmaking] Backend randomized map to {randomMap.Map.Name} for max players {reqPlayers}"); + } + else + { + Logger.Log($"[Matchmaking] Warning: NO maps found for max players {reqPlayers} in mode {mode}!"); + } + } + + + // Matchmaking Random Map Selection + + + } + + private void SetCheckBoxValue(string checkBoxName, bool value) + { + GameLobbyCheckBox checkBox = CheckBoxes.Find(cb => + string.Equals(cb.Name, checkBoxName, StringComparison.OrdinalIgnoreCase)); + if (checkBox == null) + 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 || 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; + } +} } From b40052ba103c760445cd20f2b2c54fef3a6ed9a9 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Fri, 20 Mar 2026 22:47:54 +0100 Subject: [PATCH 02/16] fix R14 to R13 to show rooms and fix tunnelhandler --- ClientCore/ProgramConstants.cs | 2 +- .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ClientCore/ProgramConstants.cs b/ClientCore/ProgramConstants.cs index e004ff015..d5b9ae2bf 100644 --- a/ClientCore/ProgramConstants.cs +++ b/ClientCore/ProgramConstants.cs @@ -26,7 +26,7 @@ public static class ProgramConstants public const string QRES_EXECUTABLE = "qres.dat"; - public const string CNCNET_PROTOCOL_REVISION = "R14"; + public const string CNCNET_PROTOCOL_REVISION = "R13"; public const string LAN_PROTOCOL_REVISION = "RL8"; public const int LAN_PORT = 1234; public const int LAN_INGAME_PORT = 1234; diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 7031675ca..9b302dc84 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -374,7 +374,7 @@ public void OnJoined() private void UpdatePing() { - if (tunnelHandler.CurrentTunnel == null) + if (tunnelHandler == null || tunnelHandler.CurrentTunnel == null) return; channel.SendCTCPMessage("TNLPNG " + tunnelHandler.CurrentTunnel.PingInMs, QueuedMessageType.SYSTEM_MESSAGE, 10); @@ -400,7 +400,7 @@ protected override void CopyPlayerDataToUI() private void PrintTunnelServerInformation(string s) { - if (tunnelHandler.CurrentTunnel == null) + if (tunnelHandler == null || tunnelHandler.CurrentTunnel == null) { AddNotice("Tunnel server unavailable!".L10N("Client:Main:TunnelUnavailable")); } @@ -912,9 +912,9 @@ 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++) { + if (pId >= playerPorts.Count || Players[pId] == null) continue; + Players[pId].Port = playerPorts[pId]; sb.Append(";"); sb.Append(Players[pId].Name); sb.Append(";"); @@ -1511,6 +1511,11 @@ protected override void GameProcessExited() if (!string.IsNullOrEmpty(matchmakingPresetMode)) { Logger.Log($"MatchmakingGameExited: Auto-leaving room. mode={matchmakingPresetMode}"); + if (TopBar != null) + { + TopBar.AddPrimarySwitchable(this); + TopBar.SwitchToPrimary(); + } LeaveGameLobby(); return; } From c9bffc6260901e7a01e16c4d4488258135b9ffc6 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Sat, 28 Mar 2026 21:05:42 +0100 Subject: [PATCH 03/16] Dynamic Matchmaking system overhaul via INI configuration --- .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 42 +++-- .../CnCNet/MatchmakingMapDefinitions.cs | 74 ++++++++ .../Multiplayer/CnCNet/MatchmakingSettings.cs | 163 ++++++++++++++++++ .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 87 +++++----- INI/Matchmaking.ini | 47 +++++ INI/MatchmakingMaps.ini | 7 + 6 files changed, 360 insertions(+), 60 deletions(-) create mode 100644 DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs create mode 100644 DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs create mode 100644 INI/Matchmaking.ini create mode 100644 INI/MatchmakingMaps.ini diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 2553dae0a..4d4d60fdd 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -387,9 +387,14 @@ public override void Initialize() tbGameSearch.Y, MatchmakingModeWidth, UIDesignConstants.BUTTON_HEIGHT); - ddMatchmakingMode.AddItem(new XNADropDownItem { Text = "1v1" }); - ddMatchmakingMode.AddItem(new XNADropDownItem { Text = "2v2v2v2" }); - ddMatchmakingMode.SelectedIndex = 0; + MatchmakingSettings.Instance.Initialize(); + MatchmakingMapDefinitions.Instance.Initialize(); + 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( @@ -604,11 +609,18 @@ private void InitializeGameList() if (game.InternalName.ToUpper() == localGameID.ToUpper()) { - chatChannel.CTCPReceived += MatchmakingChatChannel_CTCPReceived; - chatChannel.UserAdded += MatchmakingChatChannel_UserAdded; - chatChannel.UserLeft += MatchmakingChatChannel_UserLeftOrQuit; - chatChannel.UserQuitIRC += MatchmakingChatChannel_UserLeftOrQuit; - chatChannel.UserKicked += MatchmakingChatChannel_UserLeftOrQuit; + 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)) @@ -928,8 +940,11 @@ private void BtnMatchmaking_LeftClick(object sender, EventArgs e) private string GetSelectedMatchmakingMode() => ddMatchmakingMode.SelectedItem?.Text ?? "1v1"; - private int GetRequiredPlayersForMode(string mode) => - string.Equals(mode, "2v2v2v2", StringComparison.OrdinalIgnoreCase) ? 8 : 2; + 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() { @@ -1185,8 +1200,8 @@ private void TrySendMatchmakingParticipantsToRoom(string hostName) private void SendMatchmakingChannelCommand(string payload) { - Channel matchmakingChannel = connectionManager.FindChannel( - gameCollection.GetGameChatChannelNameFromIdentifier(localGameID)); + string mmName = gameCollection.GetGameChatChannelNameFromIdentifier(localGameID) + "-mm"; + Channel matchmakingChannel = connectionManager.FindChannel(mmName); if (matchmakingChannel == null) { @@ -1826,6 +1841,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(); diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs new file mode 100644 index 000000000..8880a77a6 --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -0,0 +1,74 @@ +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 ?? (instance = new MatchmakingMapDefinitions()); + + public Dictionary> ModeMaps { get; private set; } + + private MatchmakingMapDefinitions() + { + ModeMaps = new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + public void Initialize() + { + ModeMaps.Clear(); + string iniPath = ProgramConstants.GamePath + "INI/MatchmakingMaps.ini"; + if (!System.IO.File.Exists(iniPath)) + { + CreateDefaultMaps(iniPath); + } + + IniFile ini = new IniFile(iniPath); + foreach (var section in ini.GetSections()) + { + if (string.IsNullOrEmpty(section)) continue; + + var keys = ini.GetSectionKeys(section); + if (keys == null) continue; + + var maps = new List(); + foreach (string key in keys) + { + string mapName = ini.GetStringValue(section, key, string.Empty); + if (!string.IsNullOrWhiteSpace(mapName)) + { + maps.Add(mapName.Trim()); + } + } + ModeMaps[section] = maps; + } + } + + private void CreateDefaultMaps(string path) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)); + var lines = new List + { + "[1v1]", + "Map1=Dry Heat", + "Map2=Arena Valley Extreme", + "", + "[2v2v2v2]", + "Map1=Heck Freezes Over", + "Map2=Snow Valley" + }; + System.IO.File.WriteAllLines(path, lines); + } + catch (Exception ex) + { + Logger.Log("Failed to create default MatchmakingMaps.ini: " + ex.Message); + } + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs new file mode 100644 index 000000000..3dad9a41a --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs @@ -0,0 +1,163 @@ +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; } + public int PlayerCount { get; set; } + public string[] AlliedSideNames { get; set; } + public string[] SovietSideNames { get; set; } + public string[] AlliedColors { get; set; } + public string[] SovietColors { get; set; } + public Dictionary ForceCheckboxes { get; set; } + public Dictionary ForceDropdowns { get; set; } + public bool AssignTeams { get; set; } + } + + public class MatchmakingSettings + { + private static MatchmakingSettings instance; + public static MatchmakingSettings Instance => instance ?? (instance = new MatchmakingSettings()); + + public List Modes { get; private set; } + + private MatchmakingSettings() + { + Modes = new List(); + } + + public void Initialize() + { + Modes.Clear(); + string iniPath = ProgramConstants.GamePath + "INI/Matchmaking.ini"; + if (!System.IO.File.Exists(iniPath)) + { + CreateDefaultSettings(iniPath); + } + + IniFile ini = new IniFile(iniPath); + List modeSections = ini.GetSectionKeys("MatchmakingModes"); + if (modeSections == null || modeSections.Count == 0) + return; + + foreach (string modeKey in modeSections) + { + string sectionName = ini.GetStringValue("MatchmakingModes", modeKey, string.Empty); + if (string.IsNullOrEmpty(sectionName)) continue; + + var mode = new MatchmakingModeDefinition(); + mode.UIName = sectionName; + mode.PlayerCount = ini.GetIntValue(sectionName, "MaxPlayers", 2); + + // Enforce even players + if (mode.PlayerCount % 2 != 0) + mode.PlayerCount++; + if (mode.PlayerCount > 8) mode.PlayerCount = 8; + if (mode.PlayerCount < 2) mode.PlayerCount = 2; + + string alliedSides = ini.GetStringValue(sectionName, "AlliedSides", "Allies,Allied"); + mode.AlliedSideNames = alliedSides.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + string sovietSides = ini.GetStringValue(sectionName, "SovietSides", "Soviets,Soviet"); + mode.SovietSideNames = sovietSides.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + string alliedColors = ini.GetStringValue(sectionName, "AlliedColors", "Blue,Cyan,Green,Yellow"); + mode.AlliedColors = alliedColors.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + string sovietColors = ini.GetStringValue(sectionName, "SovietColors", "Red,Orange,Purple,Pink"); + mode.SovietColors = sovietColors.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + mode.AssignTeams = ini.GetBooleanValue(sectionName, "AssignTeams", false); + + mode.ForceCheckboxes = new Dictionary(); + mode.ForceDropdowns = new Dictionary(); + + List sectionKeys = ini.GetSectionKeys(sectionName); + if (sectionKeys != null) + { + foreach (string key in sectionKeys) + { + if (key.StartsWith("chk", StringComparison.OrdinalIgnoreCase)) + { + mode.ForceCheckboxes[key] = ini.GetBooleanValue(sectionName, key, false); + } + else if (key.StartsWith("cmb", StringComparison.OrdinalIgnoreCase)) + { + mode.ForceDropdowns[key] = ini.GetStringValue(sectionName, key, string.Empty); + } + } + } + + Modes.Add(mode); + } + } + + private void CreateDefaultSettings(string path) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)); + var lines = new List + { + "[MatchmakingModes]", + "0=1v1", + "1=2v2v2v2", + "", + "[1v1]", + "MaxPlayers=2", + "AlliedSides=Allies,Allied", + "SovietSides=Soviets,Soviet", + "AlliedColors=Blue,Green,Cyan,Yellow", + "SovietColors=Red,Orange,Purple,Pink", + "chkShortGame=True", + "chkRedeplMCV=True", + "chkAutoRepair=False", + "chkMultiEng=False", + "chkIngameAllying=True", + "chkDestrBridges=True", + "chkBuildOffAlly=True", + "chkCrates=False", + "chkDisableGameSpeed=True", + "chkSuperWeapons=False", + "chkNoYuri=True", + "cmbCredits=10000", + "cmbStartingUnits=0", + "cmbGameSpeedCapMultiplayer=0", + "AssignTeams=False", + "", + "[2v2v2v2]", + "MaxPlayers=8", + "AlliedSides=Allies,Allied", + "SovietSides=Soviets,Soviet", + "AlliedColors=Blue,Green,Cyan,Yellow", + "SovietColors=Red,Orange,Purple,Pink", + "chkShortGame=True", + "chkRedeplMCV=True", + "chkAutoRepair=False", + "chkMultiEng=False", + "chkIngameAllying=True", + "chkDestrBridges=True", + "chkBuildOffAlly=True", + "chkCrates=False", + "chkDisableGameSpeed=True", + "chkSuperWeapons=False", + "chkNoYuri=True", + "cmbCredits=10000", + "cmbStartingUnits=0", + "cmbGameSpeedCapMultiplayer=0", + "AssignTeams=True" + }; + System.IO.File.WriteAllLines(path, lines); + } + catch (Exception ex) + { + Logger.Log("Failed to create default Matchmaking.ini: " + ex.Message); + } + } + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 9b302dc84..68b4400c3 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -2451,30 +2451,26 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) if (!IsHost || string.IsNullOrEmpty(matchmakingPresetMode) || Players.Count == 0) return false; - bool is1v1Mode = string.Equals(matchmakingPresetMode, "1v1", StringComparison.OrdinalIgnoreCase); - bool is2v2v2v2Mode = string.Equals(matchmakingPresetMode, "2v2v2v2", StringComparison.OrdinalIgnoreCase); - if (!is1v1Mode && !is2v2v2v2Mode) + var modeDef = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, matchmakingPresetMode, StringComparison.OrdinalIgnoreCase)); + if (modeDef == null) return false; - int alliedSideIndex = FindSideIndex(MATCHMAKING_ALLIED_SIDE_NAMES); - int sovietSideIndex = FindSideIndex(MATCHMAKING_SOVIET_SIDE_NAMES); + 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 = is2v2v2v2Mode && + bool canAssignTeams = modeDef.AssignTeams && GameModeMap != null && !GameModeMap.IsCoop && !GameModeMap.ForceNoTeams && !GetPlayerExtraOptions().IsForceNoTeams; - string[] preferredColorNames = is1v1Mode - ? MATCHMAKING_1V1_COLOR_PRIORITY - : MATCHMAKING_2V2V2V2_COLOR_PRIORITY; - - int playerCountToAssign = Math.Min(Players.Count, is2v2v2v2Mode ? 8 : 2); + int playerCountToAssign = Math.Min(Players.Count, modeDef.PlayerCount); int maximumTeamId = ProgramConstants.TEAMS.Count; bool anyChanged = false; var usedColorIndices = new HashSet(); @@ -2484,6 +2480,7 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) 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; @@ -2531,44 +2528,43 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) private void ApplyMatchmakingOptionPreset(string mode) { + var def = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, mode, StringComparison.OrdinalIgnoreCase)); + if (def == null) return; + foreach (var kvp in def.ForceCheckboxes) + SetCheckBoxValue(kvp.Key, kvp.Value); - SetCheckBoxValue("chkShortGame", true); - SetCheckBoxValue("chkRedeplMCV", true); - SetCheckBoxValue("chkAutoRepair", false); - SetCheckBoxValue("chkMultiEng", false); - SetCheckBoxValue("chkIngameAllying", true); - SetCheckBoxValue("chkDestrBridges", true); - SetCheckBoxValue("chkBuildOffAlly", true); - SetCheckBoxValue("chkCrates", false); - SetCheckBoxValue("chkDisableGameSpeed", true); - - // User requested presets - SetCheckBoxValue("chkNoYuri", true); - SetCheckBoxValue("chkSuperWeapons", false); - SetCheckBoxValue("chkIngameAllying", true); - SetCheckBoxValue("chkDestrBridges", true); - SetCheckBoxValue("chkBuildOffAlly", true); - SetCheckBoxValue("chkCrates", false); - SetCheckBoxValue("chkDisableGameSpeed", true); - - SetDropDownValueByText("cmbCredits", "10000"); - SetDropDownValueByText("cmbStartingUnits", "0"); - SetDropDownValueByIndex("cmbGameSpeedCapMultiplayer", 0); - - // Super weapons check - GameLobbyDropDown superWeaponsDropDown = FindDropDown("cmbSuperWeaponsModifier"); - if (superWeaponsDropDown != null) - - - SetDropDownValueByIndex(superWeaponsDropDown.Name, superWeaponsDropDown.Items.Count - 1); + foreach (var kvp in def.ForceDropdowns) + { + if (int.TryParse(kvp.Value, out int idx)) + SetDropDownValueByIndex(kvp.Key, idx); + else + SetDropDownValueByText(kvp.Key, kvp.Value); + } // Matchmaking Random Map Selection (Strictly Backend Filtered) if (GameModeMaps != null && GameModeMaps.Count > 0) { - int reqPlayers = string.Equals(mode, "2v2v2v2", StringComparison.OrdinalIgnoreCase) ? 8 : 2; - var suitableMaps = GameModeMaps.Where(m => m.Map != null && m.Map.MaxPlayers == reqPlayers).ToList(); + int reqPlayers = def.PlayerCount; + var suitableMaps = new List(); + + // 1. Try to find maps from the defined list in MatchmakingMaps.ini + if (MatchmakingMapDefinitions.Instance.ModeMaps.TryGetValue(mode, out var definedMapNames) && definedMapNames.Count > 0) + { + suitableMaps = GameModeMaps.Where(m => + m.Map != null && + m.Map.MaxPlayers == reqPlayers && + definedMapNames.Any(dmn => string.Equals(m.Map.Name, dmn, StringComparison.OrdinalIgnoreCase) || string.Equals(m.Map.UntranslatedName, dmn, StringComparison.OrdinalIgnoreCase)) + ).ToList(); + } + // 2. Fallback if no matching defined maps were found + if (suitableMaps.Count == 0) + { + Logger.Log($"[Matchmaking] Warning: Could not find any maps from defined list for {mode}. Falling back to random max_players matching."); + suitableMaps = GameModeMaps.Where(m => m.Map != null && m.Map.MaxPlayers == reqPlayers).ToList(); + } + if (suitableMaps.Count > 0) { Random rnd = new Random(); @@ -2578,14 +2574,9 @@ private void ApplyMatchmakingOptionPreset(string mode) } else { - Logger.Log($"[Matchmaking] Warning: NO maps found for max players {reqPlayers} in mode {mode}!"); + Logger.Log($"[Matchmaking] Error: NO maps found for max players {reqPlayers} in mode {mode}!"); } } - - - // Matchmaking Random Map Selection - - } private void SetCheckBoxValue(string checkBoxName, bool value) diff --git a/INI/Matchmaking.ini b/INI/Matchmaking.ini new file mode 100644 index 000000000..fc7e09e91 --- /dev/null +++ b/INI/Matchmaking.ini @@ -0,0 +1,47 @@ +[MatchmakingModes] +0=1v1 +1=2v2v2v2 + +[1v1] +MaxPlayers=2 +AlliedSides=Allies,Allied +SovietSides=Soviets,Soviet +AlliedColors=Blue,Green,Cyan,Yellow +SovietColors=Red,Orange,Purple,Pink +chkShortGame=True +chkRedeplMCV=True +chkAutoRepair=False +chkMultiEng=False +chkIngameAllying=True +chkDestrBridges=True +chkBuildOffAlly=True +chkCrates=False +chkDisableGameSpeed=True +chkSuperWeapons=False +chkNoYuri=True +cmbCredits=10000 +cmbStartingUnits=0 +cmbGameSpeedCapMultiplayer=0 +AssignTeams=False + +[2v2v2v2] +MaxPlayers=8 +AlliedSides=Allies,Allied +SovietSides=Soviets,Soviet +AlliedColors=Blue,Green,Cyan,Yellow +SovietColors=Red,Orange,Purple,Pink +chkShortGame=True +chkRedeplMCV=True +chkAutoRepair=False +chkMultiEng=False +chkIngameAllying=True +chkDestrBridges=True +chkBuildOffAlly=True +chkCrates=False +chkDisableGameSpeed=True +chkSuperWeapons=False +chkNoYuri=True +cmbCredits=10000 +cmbStartingUnits=0 +cmbGameSpeedCapMultiplayer=0 +AssignTeams=True diff --git a/INI/MatchmakingMaps.ini b/INI/MatchmakingMaps.ini new file mode 100644 index 000000000..b9d4d5918 --- /dev/null +++ b/INI/MatchmakingMaps.ini @@ -0,0 +1,7 @@ +[1v1] +Map1=Dry Heat +Map2=Arena Valley Extreme + +[2v2v2v2] +Map1=Heck Freezes Over +Map2=Snow Valley From 8af12eb67eb5584fde6bc147005196af29649eb2 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Sat, 28 Mar 2026 22:41:17 +0100 Subject: [PATCH 04/16] Un commit b40052ba103c760445cd20f2b2c54fef3a6ed9a9 --- ClientCore/ProgramConstants.cs | 2 +- .../DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/ClientCore/ProgramConstants.cs b/ClientCore/ProgramConstants.cs index d5b9ae2bf..e004ff015 100644 --- a/ClientCore/ProgramConstants.cs +++ b/ClientCore/ProgramConstants.cs @@ -26,7 +26,7 @@ public static class ProgramConstants public const string QRES_EXECUTABLE = "qres.dat"; - public const string CNCNET_PROTOCOL_REVISION = "R13"; + public const string CNCNET_PROTOCOL_REVISION = "R14"; public const string LAN_PROTOCOL_REVISION = "RL8"; public const int LAN_PORT = 1234; public const int LAN_INGAME_PORT = 1234; diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 68b4400c3..636c891e3 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -374,7 +374,7 @@ public void OnJoined() private void UpdatePing() { - if (tunnelHandler == null || tunnelHandler.CurrentTunnel == null) + if (tunnelHandler.CurrentTunnel == null) return; channel.SendCTCPMessage("TNLPNG " + tunnelHandler.CurrentTunnel.PingInMs, QueuedMessageType.SYSTEM_MESSAGE, 10); @@ -400,7 +400,7 @@ protected override void CopyPlayerDataToUI() private void PrintTunnelServerInformation(string s) { - if (tunnelHandler == null || tunnelHandler.CurrentTunnel == null) + if (tunnelHandler.CurrentTunnel == null) { AddNotice("Tunnel server unavailable!".L10N("Client:Main:TunnelUnavailable")); } @@ -913,7 +913,6 @@ protected override void HostLaunchGame() StringBuilder sb = new StringBuilder("START "); sb.Append(UniqueGameID); for (int pId = 0; pId < Players.Count; pId++) { - if (pId >= playerPorts.Count || Players[pId] == null) continue; Players[pId].Port = playerPorts[pId]; sb.Append(";"); sb.Append(Players[pId].Name); @@ -1511,11 +1510,6 @@ protected override void GameProcessExited() if (!string.IsNullOrEmpty(matchmakingPresetMode)) { Logger.Log($"MatchmakingGameExited: Auto-leaving room. mode={matchmakingPresetMode}"); - if (TopBar != null) - { - TopBar.AddPrimarySwitchable(this); - TopBar.SwitchToPrimary(); - } LeaveGameLobby(); return; } From 94192d0f9de2dff4ab4a8a43c7b91064dd2e8669 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Sun, 29 Mar 2026 03:01:17 +0200 Subject: [PATCH 05/16] Stabilize matchmaking map selection and enforce code styles - Implement fuzzy map name matching in CnCNetGameLobby to ignore player count prefixes - Highlight auto-selected matchmaking map dynamically via RefreshMapSelectionUI - Add '#nullable enable' annotations to all core matchmaking components - Replace implicit 'var' declarations with explicit types across the solution - Enforce strict block spacing and formatting rules per project guidelines --- .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 18 +- .../Multiplayer/CnCNet/MatchmakingLogger.cs | 58 ++++++- .../CnCNet/MatchmakingMapDefinitions.cs | 31 +++- .../Multiplayer/CnCNet/MatchmakingService.cs | 164 ++++++++++++------ .../Multiplayer/CnCNet/MatchmakingSettings.cs | 48 +++-- .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 121 +++++++++++-- INI/MatchmakingMaps.ini | 7 +- 7 files changed, 337 insertions(+), 110 deletions(-) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 8f6e3cd86..673811595 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -98,6 +98,8 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private DarkeningPanel gameCreationPanel; private Channel currentChatChannel; + private DateTime lastMatchmakingClickTime = DateTime.MinValue; + private GameCollection gameCollection; @@ -923,11 +925,21 @@ private void BtnJoinGame_LeftClick(object sender, EventArgs e) private void BtnMatchmaking_LeftClick(object sender, EventArgs e) { + if (DateTime.Now - lastMatchmakingClickTime < TimeSpan.FromSeconds(2)) + { + matchmakingLogger?.Info("BtnClickThrottled", "Click ignored due to 2s cooldown"); + return; + } + + lastMatchmakingClickTime = DateTime.Now; + if (gameLobby.Enabled) { + matchmakingLogger?.Info("BtnClickLeavingLobby", "Leaving lobby before matchmaking toggle"); gameLobby.LeaveGameLobby(); - isInGameRoom = false; // Fix race condition for immediate queue toggle + isInGameRoom = false; } + if (gameLoadingLobby.Enabled) { gameLoadingLobby.Clear(); @@ -1245,7 +1257,7 @@ private void TrackHiddenMatchmakingRoom(string channelName, string roomName, str bool removedAny = false; for (int i = lbGameList.HostedGames.Count - 1; i >= 0; i--) { - var hostedGame = (HostedCnCNetGame)lbGameList.HostedGames[i]; + HostedCnCNetGame hostedGame = (HostedCnCNetGame)lbGameList.HostedGames[i]; bool channelMatches = !string.IsNullOrWhiteSpace(channelName) && string.Equals(hostedGame.ChannelName, channelName, StringComparison.OrdinalIgnoreCase); bool roomMatches = !string.IsNullOrWhiteSpace(roomName) && @@ -1326,7 +1338,7 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri AddMainChannelNotice($"Match found ({mode}). Joining room..."); - var hostedGame = new HostedCnCNetGame( + HostedCnCNetGame hostedGame = new HostedCnCNetGame( channelName, ProgramConstants.CNCNET_PROTOCOL_REVISION, ProgramConstants.GAME_VERSION, diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs index 720baebd7..f63c51388 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs @@ -1,27 +1,66 @@ +#nullable enable + using Rampastring.Tools; using System; +using System.IO; namespace DTAClient.DXGUI.Multiplayer.CnCNet { internal sealed class MatchmakingLogger { - private readonly Func localPlayerNameProvider; + private readonly Func? localPlayerNameProvider; + private readonly string logFilePath; + private readonly object fileLock = new object(); - public MatchmakingLogger(Func localPlayerNameProvider) + public MatchmakingLogger(Func? localPlayerNameProvider) { this.localPlayerNameProvider = localPlayerNameProvider; + + // Log to Client/Logs/Matchmaking.log + string logDir = Path.Combine(ClientCore.ProgramConstants.GamePath, "Client", "Logs"); + if (!Directory.Exists(logDir)) + Directory.CreateDirectory(logDir); + + logFilePath = Path.Combine(logDir, "Matchmaking.log"); + + // Write a start session marker + LogToFile("--- NEW MATCHMAKING SESSION STARTED ---"); } - public void Info(string eventName, string details = null) => - Logger.Log(Format("INFO", eventName, details)); + public void Info(string eventName, string? details = null) => + Log("INFO", eventName, details); + + public void Warn(string eventName, string? details = null) => + Log("WARN", eventName, details); - public void Warn(string eventName, string details = null) => - Logger.Log(Format("WARN", eventName, details)); + public void Error(string eventName, Exception ex, string? details = null) => + Log("ERROR", eventName, $"{details} :: {ex}"); + + private void Log(string level, string eventName, string? details) + { + string formatted = Format(level, eventName, details); + + // Also log to the main client.log for redundancy + Logger.Log(formatted); + + // Log to our dedicated file + LogToFile(formatted); + } - public void Error(string eventName, Exception ex, string details = null) => - Logger.Log(Format("ERROR", eventName, $"{details} :: {ex}")); + private void LogToFile(string message) + { + try + { + lock (fileLock) + { + string timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); + File.AppendAllText(logFilePath, $"[{timestamp}] {message}{Environment.NewLine}"); + } + } + catch { /* Ignore logging failures to prevent app crashes */ } + } - private string Format(string level, string eventName, string details) + private string Format(string level, string eventName, string? details) { string localPlayerName = localPlayerNameProvider?.Invoke() ?? string.Empty; @@ -32,3 +71,4 @@ private string Format(string level, string eventName, string details) } } } + diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs index 8880a77a6..fc1d23265 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Collections.Generic; using System.Linq; @@ -8,8 +10,9 @@ namespace DTAClient.DXGUI.Multiplayer.CnCNet { public class MatchmakingMapDefinitions { - private static MatchmakingMapDefinitions instance; - public static MatchmakingMapDefinitions Instance => instance ?? (instance = new MatchmakingMapDefinitions()); + private static MatchmakingMapDefinitions? instance; + + public static MatchmakingMapDefinitions Instance => instance ??= new MatchmakingMapDefinitions(); public Dictionary> ModeMaps { get; private set; } @@ -22,28 +25,35 @@ public void Initialize() { ModeMaps.Clear(); string iniPath = ProgramConstants.GamePath + "INI/MatchmakingMaps.ini"; + if (!System.IO.File.Exists(iniPath)) { CreateDefaultMaps(iniPath); } - IniFile ini = new IniFile(iniPath); - foreach (var section in ini.GetSections()) + var ini = new IniFile(iniPath); + + foreach (string section in ini.GetSections()) { - if (string.IsNullOrEmpty(section)) continue; + if (string.IsNullOrEmpty(section)) + continue; - var keys = ini.GetSectionKeys(section); - if (keys == null) continue; + List? keys = ini.GetSectionKeys(section); + if (keys == null) + continue; var maps = new List(); + foreach (string key in keys) { string mapName = ini.GetStringValue(section, key, string.Empty); + if (!string.IsNullOrWhiteSpace(mapName)) { maps.Add(mapName.Trim()); } } + ModeMaps[section] = maps; } } @@ -52,7 +62,11 @@ private void CreateDefaultMaps(string path) { try { - System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)); + string? directoryPath = System.IO.Path.GetDirectoryName(path); + + if (!string.IsNullOrEmpty(directoryPath)) + System.IO.Directory.CreateDirectory(directoryPath); + var lines = new List { "[1v1]", @@ -63,6 +77,7 @@ private void CreateDefaultMaps(string path) "Map1=Heck Freezes Over", "Map2=Snow Valley" }; + System.IO.File.WriteAllLines(path, lines); } catch (Exception ex) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs index 79cfac28d..b9da335a0 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +18,7 @@ internal sealed class MatchmakingService private readonly Random random; private readonly MatchmakingLogger logger; - private readonly Func selectedModeProvider; + private readonly Func? selectedModeProvider; private readonly Func canJoinQueue; private readonly Func canHostMatch; private readonly Func requiredPlayersForMode; @@ -24,7 +26,7 @@ internal sealed class MatchmakingService private readonly Action addNotice; private readonly Action setQueueUiState; private readonly Action> localMatchClaimedCallback; - private readonly Func localPlayerNameProvider; + private readonly Func? localPlayerNameProvider; private readonly Dictionary> queues = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -34,14 +36,17 @@ internal sealed class MatchmakingService new HashSet(StringComparer.OrdinalIgnoreCase); private bool isInQueue; - private string queueMode; - private string queueTicket; + 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? localPlayerNameProvider, MatchmakingLogger logger, - Func selectedModeProvider, + Func? selectedModeProvider, Func canJoinQueue, Func canHostMatch, Func requiredPlayersForMode, @@ -64,10 +69,23 @@ public MatchmakingService( } public bool IsInQueue => isInQueue; + private string LocalPlayerName => localPlayerNameProvider?.Invoke() ?? string.Empty; public void ToggleQueue() { + if (DateTime.Now.Subtract(lastActionTime).TotalMilliseconds < ActionCooldownMs) + { + logger.Warn("ActionThrottled", $"cooldown_remaining={ActionCooldownMs - DateTime.Now.Subtract(lastActionTime).TotalMilliseconds}ms"); + return; + } + + if (isBusy) + { + logger.Warn("ActionBlocked", "is_busy"); + return; + } + if (isInQueue) { LeaveQueue(true, true); @@ -77,20 +95,76 @@ public void ToggleQueue() 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."); + logger.Warn("QueueJoinRejected", "client_not_ready"); + return; + } + + string mode = selectedModeProvider?.Invoke() ?? string.Empty; + + if (string.IsNullOrEmpty(mode)) + { + logger.Warn("QueueJoinRejected", "empty_mode"); + return; + } + + string localPlayerName = LocalPlayerName; + + if (string.IsNullOrEmpty(localPlayerName)) + { + logger.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})."); + + logger.Info("QueueJoined", $"mode={mode}, ticket={queueTicket}"); + logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + + isBusy = false; + TryClaimMatch(mode); + } + public void LeaveQueue(bool broadcastLeave, bool showMessage) { if (!isInQueue) return; - string mode = queueMode; + string? mode = queueMode; logger.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); @@ -100,6 +174,8 @@ public void LeaveQueue(bool broadcastLeave, bool showMessage) if (showMessage) addNotice("Left matchmaking queue."); + + isBusy = false; } public void Reset() @@ -117,6 +193,7 @@ public void HandleChannelCommand(string sender, string commandData) return; string[] parts = commandData.Split(';'); + if (parts.Length == 0) return; @@ -151,46 +228,10 @@ public void HandleUserLeftOrQuit(string playerName) RemovePlayerFromAllQueues(playerName); - foreach (var mode in queues.Keys.ToList()) - TryClaimMatch(mode); - } - - private void StartQueue() - { - if (!canJoinQueue()) - { - addNotice("Cannot join matchmaking queue while already joining or inside a game room."); - logger.Warn("QueueJoinRejected", "client_not_ready"); - return; - } - - string mode = selectedModeProvider?.Invoke() ?? string.Empty; - if (string.IsNullOrEmpty(mode)) - { - logger.Warn("QueueJoinRejected", "empty_mode"); - return; - } - - string localPlayerName = LocalPlayerName; - if (string.IsNullOrEmpty(localPlayerName)) + foreach (string mode in queues.Keys.ToList()) { - logger.Warn("QueueJoinRejected", "empty_local_player_name"); - addNotice("Cannot join matchmaking queue: missing local player name."); - return; + TryClaimMatch(mode); } - - 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})."); - logger.Info("QueueJoined", $"mode={mode}, ticket={queueTicket}"); - logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); - - TryClaimMatch(mode); } private void HandleQueueJoin(string sender, string mode, string ticket) @@ -199,6 +240,7 @@ private void HandleQueueJoin(string sender, string mode, string ticket) return; AddOrUpdateQueueEntry(sender, mode, ticket); + logger.Info("QueueJoinApplied", $"sender={sender}, mode={mode}, ticket={ticket}"); logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); @@ -211,6 +253,7 @@ private void HandleQueueLeave(string sender, string mode) return; RemoveQueueEntryFromMode(sender, mode); + logger.Info("QueueLeaveApplied", $"sender={sender}, mode={mode}"); logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); @@ -255,6 +298,7 @@ private void HandleMatchClaim(string sender, string mode, string matchId, string } string designatedHost = participants[0]; + logger.Info("MatchClaimAccepted", $"matchId={matchId}, mode={mode}, sender={sender}, host={designatedHost}, participants={string.Join(",", participants)}"); RemovePlayersFromQueues(participants); @@ -266,9 +310,13 @@ private void HandleMatchClaim(string sender, string mode, string matchId, string ClearQueueState(updateUiState: true); if (string.Equals(LocalPlayerName, designatedHost, StringComparison.OrdinalIgnoreCase)) + { localMatchClaimedCallback(mode, participants); + } else if (localPlayerInMatch) + { logger.Info("MatchClaimAwaitingHost", $"matchId={matchId}, mode={mode}, expectedHost={designatedHost}"); + } TryClaimMatch(mode); } @@ -278,25 +326,27 @@ private void TryClaimMatch(string mode) if (string.IsNullOrEmpty(mode)) return; - if (!queues.TryGetValue(mode, out List queue)) + if (!queues.TryGetValue(mode, out List? queue)) { logger.Info("MatchClaimSkipped", $"mode={mode}, reason=queue_missing"); return; } int requiredPlayers = requiredPlayersForMode(mode); - if (queue.Count < requiredPlayers) + + if (queue == null || queue.Count < requiredPlayers) { - logger.Info("MatchClaimWaiting", $"mode={mode}, queued={queue.Count}, required={requiredPlayers}"); + logger.Info("MatchClaimWaiting", $"mode={mode}, queued={queue?.Count ?? 0}, required={requiredPlayers}"); return; } - var participants = queue + List participants = queue .OrderBy(qe => qe.PlayerName, StringComparer.OrdinalIgnoreCase) .Take(requiredPlayers) .ToList(); string localPlayerName = LocalPlayerName; + if (!participants.Any(p => string.Equals(p.PlayerName, localPlayerName, StringComparison.OrdinalIgnoreCase))) { logger.Info("MatchClaimSkipped", $"mode={mode}, reason=local_not_in_participants, local={localPlayerName}, participants={string.Join(",", participants.Select(p => p.PlayerName))}"); @@ -310,6 +360,7 @@ private void TryClaimMatch(string mode) } string matchId = $"{mode}:{string.Join("|", participants.Select(p => p.PlayerName + ":" + p.Ticket))}"; + if (handledMatchIds.Contains(matchId) || pendingClaimIds.Contains(matchId)) { logger.Info("MatchClaimSkipped", $"mode={mode}, reason=already_pending_or_handled, matchId={matchId}"); @@ -319,6 +370,7 @@ private void TryClaimMatch(string mode) pendingClaimIds.Add(matchId); string participantList = string.Join(",", participants.Select(p => p.PlayerName)); + logger.Info("MatchClaimBroadcast", $"matchId={matchId}, mode={mode}, sender={localPlayerName}, participants={participantList}"); sendQueueCommand($"{CommandMatch};{mode};{matchId};{participantList}"); @@ -329,7 +381,7 @@ private void TryClaimMatch(string mode) private string GetQueueSnapshot(string mode) { - if (string.IsNullOrEmpty(mode) || !queues.TryGetValue(mode, out List queue) || queue.Count == 0) + if (string.IsNullOrEmpty(mode) || !queues.TryGetValue(mode, out List? queue) || queue == null || queue.Count == 0) return "(empty)"; return string.Join(",", queue @@ -344,7 +396,7 @@ private void AddOrUpdateQueueEntry(string playerName, string mode, string ticket RemovePlayerFromAllQueues(playerName); - if (!queues.TryGetValue(mode, out List queue)) + if (!queues.TryGetValue(mode, out List? queue) || queue == null) { queue = new List(); queues[mode] = queue; @@ -359,7 +411,7 @@ private void AddOrUpdateQueueEntry(string playerName, string mode, string ticket private void RemoveQueueEntryFromMode(string playerName, string mode) { - if (!queues.TryGetValue(mode, out List queue)) + if (!queues.TryGetValue(mode, out List? queue) || queue == null) return; queue.RemoveAll(qe => string.Equals(qe.PlayerName, playerName, StringComparison.OrdinalIgnoreCase)); @@ -374,13 +426,17 @@ private void RemovePlayerFromAllQueues(string 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) @@ -395,8 +451,8 @@ private void ClearQueueState(bool updateUiState) private sealed class QueueEntry { - public string PlayerName { get; set; } - public string Ticket { get; set; } + 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 index 3dad9a41a..5bcaef327 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Collections.Generic; using System.Linq; @@ -8,21 +10,22 @@ namespace DTAClient.DXGUI.Multiplayer.CnCNet { public class MatchmakingModeDefinition { - public string UIName { get; set; } + public string UIName { get; set; } = string.Empty; public int PlayerCount { get; set; } - public string[] AlliedSideNames { get; set; } - public string[] SovietSideNames { get; set; } - public string[] AlliedColors { get; set; } - public string[] SovietColors { get; set; } - public Dictionary ForceCheckboxes { get; set; } - public Dictionary ForceDropdowns { 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 ?? (instance = new MatchmakingSettings()); + private static MatchmakingSettings? instance; + + public static MatchmakingSettings Instance => instance ??= new MatchmakingSettings(); public List Modes { get; private set; } @@ -35,20 +38,24 @@ public void Initialize() { Modes.Clear(); string iniPath = ProgramConstants.GamePath + "INI/Matchmaking.ini"; + if (!System.IO.File.Exists(iniPath)) { CreateDefaultSettings(iniPath); } - IniFile ini = new IniFile(iniPath); - List modeSections = ini.GetSectionKeys("MatchmakingModes"); + var ini = new IniFile(iniPath); + List? modeSections = ini.GetSectionKeys("MatchmakingModes"); + if (modeSections == null || modeSections.Count == 0) return; foreach (string modeKey in modeSections) { string sectionName = ini.GetStringValue("MatchmakingModes", modeKey, string.Empty); - if (string.IsNullOrEmpty(sectionName)) continue; + + if (string.IsNullOrEmpty(sectionName)) + continue; var mode = new MatchmakingModeDefinition(); mode.UIName = sectionName; @@ -57,8 +64,11 @@ public void Initialize() // Enforce even players if (mode.PlayerCount % 2 != 0) mode.PlayerCount++; - if (mode.PlayerCount > 8) mode.PlayerCount = 8; - if (mode.PlayerCount < 2) mode.PlayerCount = 2; + + if (mode.PlayerCount > 8) + mode.PlayerCount = 8; + if (mode.PlayerCount < 2) + mode.PlayerCount = 2; string alliedSides = ini.GetStringValue(sectionName, "AlliedSides", "Allies,Allied"); mode.AlliedSideNames = alliedSides.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); @@ -77,7 +87,8 @@ public void Initialize() mode.ForceCheckboxes = new Dictionary(); mode.ForceDropdowns = new Dictionary(); - List sectionKeys = ini.GetSectionKeys(sectionName); + List? sectionKeys = ini.GetSectionKeys(sectionName); + if (sectionKeys != null) { foreach (string key in sectionKeys) @@ -101,7 +112,11 @@ private void CreateDefaultSettings(string path) { try { - System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)); + string? directoryPath = System.IO.Path.GetDirectoryName(path); + + if (!string.IsNullOrEmpty(directoryPath)) + System.IO.Directory.CreateDirectory(directoryPath); + var lines = new List { "[MatchmakingModes]", @@ -152,6 +167,7 @@ private void CreateDefaultSettings(string path) "cmbGameSpeedCapMultiplayer=0", "AssignTeams=True" }; + System.IO.File.WriteAllLines(path, lines); } catch (Exception ex) diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 636c891e3..317d5575f 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 { @@ -1648,6 +1649,20 @@ protected override void StartGame() if (IsHiddenMatchmakingRoom()) { TopBar.AddPrimarySwitchable(this); + + if (IsHost) + { + AddNotice("Matchmaking: Auto-leaving room in 60 seconds...".L10N("Client:Main:AutoLeaveNotice")); + _ = Task.Run(async () => + { + await Task.Delay(60000); + if (Enabled && !string.IsNullOrEmpty(matchmakingPresetMode)) + { + Logger.Log("Matchmaking: 60s timeout reached, leaving room."); + LeaveGameLobby(); + } + }); + } } } @@ -2331,7 +2346,7 @@ private void AccelerateGameBroadcasting() => private void BroadcastGame() { - if (channel == null || connectionManager == null) + if (channel == null || connectionManager == null || gameCollection == null || tunnelHandler == null) return; Channel broadcastChannel = connectionManager.FindChannel(gameCollection.GetGameBroadcastingChannelNameFromIdentifier(localGame)); @@ -2374,7 +2389,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(";"); @@ -2522,13 +2540,18 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) private void ApplyMatchmakingOptionPreset(string mode) { - var def = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, mode, StringComparison.OrdinalIgnoreCase)); - if (def == null) return; + MatchmakingModeDefinition def = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, mode, StringComparison.OrdinalIgnoreCase)); - foreach (var kvp in def.ForceCheckboxes) + if (def == null) + { + Logger.Log($"[Matchmaking] Error: Matchmaking mode '{mode}' not found in settings."); + return; + } + + foreach (KeyValuePair kvp in def.ForceCheckboxes) SetCheckBoxValue(kvp.Key, kvp.Value); - foreach (var kvp in def.ForceDropdowns) + foreach (KeyValuePair kvp in def.ForceDropdowns) { if (int.TryParse(kvp.Value, out int idx)) SetDropDownValueByIndex(kvp.Key, idx); @@ -2539,41 +2562,107 @@ private void ApplyMatchmakingOptionPreset(string mode) // 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 - if (MatchmakingMapDefinitions.Instance.ModeMaps.TryGetValue(mode, out var definedMapNames) && definedMapNames.Count > 0) + if (MatchmakingMapDefinitions.Instance.ModeMaps.TryGetValue(mode, out List definedMapNames) && definedMapNames.Count > 0) + { + Logger.Log($"[Matchmaking] Looking for maps in defined INI list: {string.Join(", ", definedMapNames)}"); + + foreach (GameModeMap m in GameModeMaps) + { + if (m.Map == null) + continue; + + // Flexible matching: check exact name, untranslated name, base file path, + // and also try stripping the "[2]" style player count prefix/suffix often found in RA2 map names. + bool nameMatched = definedMapNames.Any(dmn => + { + string target = dmn.Trim(); + + // Exact matches + if (string.Equals(m.Map.Name, target, StringComparison.OrdinalIgnoreCase) || + string.Equals(m.Map.UntranslatedName, target, StringComparison.OrdinalIgnoreCase) || + (m.Map.BaseFilePath != null && string.Equals(m.Map.BaseFilePath, target, StringComparison.OrdinalIgnoreCase))) + return true; + + // Fuzzy match: Strip "[2] " prefix or similar from both and compare + string cleanMapName = StripMapPrefix(m.Map.Name); + string cleanTarget = StripMapPrefix(target); + + return string.Equals(cleanMapName, cleanTarget, StringComparison.OrdinalIgnoreCase); + }); + + if (nameMatched) + { + if (m.Map.MaxPlayers == reqPlayers) + { + Logger.Log($"[Matchmaking] Map OK: '{m.Map.Name}' (file: {m.Map.BaseFilePath}) matches INI and player count ({m.Map.MaxPlayers})."); + suitableMaps.Add(m); + } + else + { + Logger.Log($"[Matchmaking] Map REJECTED: '{m.Map.Name}' (file: {m.Map.BaseFilePath}) matches INI but has {m.Map.MaxPlayers} players (Mode needs {reqPlayers}). skipping."); + } + } + } + } + else { - suitableMaps = GameModeMaps.Where(m => - m.Map != null && - m.Map.MaxPlayers == reqPlayers && - definedMapNames.Any(dmn => string.Equals(m.Map.Name, dmn, StringComparison.OrdinalIgnoreCase) || string.Equals(m.Map.UntranslatedName, dmn, StringComparison.OrdinalIgnoreCase)) - ).ToList(); + Logger.Log($"[Matchmaking] No maps defined in MatchmakingMaps.ini for mode '{mode}'."); } // 2. Fallback if no matching defined maps were found if (suitableMaps.Count == 0) { - Logger.Log($"[Matchmaking] Warning: Could not find any maps from defined list for {mode}. Falling back to random max_players matching."); + Logger.Log($"[Matchmaking] Warning: No suitable maps from INI found for {mode}. (Target names were: {string.Join(", ", definedMapNames ?? 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(); - var randomMap = suitableMaps[rnd.Next(suitableMaps.Count)]; + GameModeMap randomMap = suitableMaps[rnd.Next(suitableMaps.Count)]; ChangeMap(randomMap); - Logger.Log($"[Matchmaking] Backend randomized map to {randomMap.Map.Name} for max players {reqPlayers}"); + + // 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 maps found for max players {reqPlayers} in mode {mode}!"); + 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)); diff --git a/INI/MatchmakingMaps.ini b/INI/MatchmakingMaps.ini index b9d4d5918..a6b3e4188 100644 --- a/INI/MatchmakingMaps.ini +++ b/INI/MatchmakingMaps.ini @@ -1,7 +1,6 @@ [1v1] -Map1=Dry Heat -Map2=Arena Valley Extreme +Map1=Blood Feud +Map2=May Day [2v2v2v2] -Map1=Heck Freezes Over -Map2=Snow Valley +Map1=Invasion From 4a5662322a81dffdaacc95189c0929c5100881a0 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Sun, 29 Mar 2026 15:18:39 +0200 Subject: [PATCH 06/16] UI: Restore color selection and implement dynamic top-bar layout --- .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 78 ++++++++++++++++--- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 673811595..9acb9be16 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -94,6 +94,7 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private XNAClientToggleButton btnGameFilterOptions; private XNAClientDropDown ddMatchmakingMode; + private XNALabel lblMatchmakingMode; private DarkeningPanel gameCreationPanel; @@ -389,6 +390,11 @@ public override void Initialize() 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(); foreach (var mode in MatchmakingSettings.Instance.Modes) @@ -432,6 +438,7 @@ public override void Initialize() AddChild(lblOnlineCount); AddChild(tbGameSearch); AddChild(ddMatchmakingMode); + AddChild(lblMatchmakingMode); AddChild(btnGameSortAlpha); AddChild(btnGameFilterOptions); @@ -1042,34 +1049,83 @@ private void LayoutTopLobbyControls() private void LayoutCurrentChannelAndMatchmakingModeControls() { - if (ddCurrentChannel == null || lblCurrentChannel == null || ddMatchmakingMode == null || lbChatMessages == null || ddColor == null) + if (ddCurrentChannel == null || lblCurrentChannel == null || ddMatchmakingMode == null || lbChatMessages == null || ddColor == null || lblMatchmakingMode == null) return; - int y = ddColor.Y; + 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( - lbChatMessages.Right - CurrentChannelWidth, + 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( - ddCurrentChannel.X - CurrentChannelLabelWidth, - ddCurrentChannel.Y + 2, + currentLeft, + y + 2, 0, 0); - int modeX = lblCurrentChannel.X - TopControlsSpacing - MatchmakingModeWidth; - int minimumModeX = btnGameFilterOptions != null - ? btnGameFilterOptions.Right + TopControlsSpacing - : lbGameList.X; - modeX = Math.Max(minimumModeX, modeX); + // 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( - modeX, + 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) => From 561158548b7ef9e90750bac07290b153b04eea89 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Sun, 29 Mar 2026 15:46:57 +0200 Subject: [PATCH 07/16] Hardcode matchmaking settings and remove external INIs --- .../CnCNet/MatchmakingMapDefinitions.cs | 65 +----- .../Multiplayer/CnCNet/MatchmakingSettings.cs | 188 ++++++------------ INI/Matchmaking.ini | 47 ----- INI/MatchmakingMaps.ini | 6 - 4 files changed, 63 insertions(+), 243 deletions(-) delete mode 100644 INI/Matchmaking.ini delete mode 100644 INI/MatchmakingMaps.ini diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs index fc1d23265..5637d448d 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -24,66 +24,15 @@ private MatchmakingMapDefinitions() public void Initialize() { ModeMaps.Clear(); - string iniPath = ProgramConstants.GamePath + "INI/MatchmakingMaps.ini"; + + // 1v1 Maps + ModeMaps["1v1"] = new List { "Blood Feud", "May Day", "Dry Heat", "Arena Valley Extreme" }; - if (!System.IO.File.Exists(iniPath)) - { - CreateDefaultMaps(iniPath); - } + // 2v2 Maps + ModeMaps["2v2"] = new List { "Heck Freezes Over", "Tournament A" }; - var ini = new IniFile(iniPath); - - foreach (string section in ini.GetSections()) - { - if (string.IsNullOrEmpty(section)) - continue; - - List? keys = ini.GetSectionKeys(section); - if (keys == null) - continue; - - var maps = new List(); - - foreach (string key in keys) - { - string mapName = ini.GetStringValue(section, key, string.Empty); - - if (!string.IsNullOrWhiteSpace(mapName)) - { - maps.Add(mapName.Trim()); - } - } - - ModeMaps[section] = maps; - } - } - - private void CreateDefaultMaps(string path) - { - try - { - string? directoryPath = System.IO.Path.GetDirectoryName(path); - - if (!string.IsNullOrEmpty(directoryPath)) - System.IO.Directory.CreateDirectory(directoryPath); - - var lines = new List - { - "[1v1]", - "Map1=Dry Heat", - "Map2=Arena Valley Extreme", - "", - "[2v2v2v2]", - "Map1=Heck Freezes Over", - "Map2=Snow Valley" - }; - - System.IO.File.WriteAllLines(path, lines); - } - catch (Exception ex) - { - Logger.Log("Failed to create default MatchmakingMaps.ini: " + ex.Message); - } + // 2v2v2v2 Maps + ModeMaps["2v2v2v2"] = new List { "Invasion", "Snow Valley" }; } } } diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs index 5bcaef327..db9c657f9 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs @@ -37,143 +37,67 @@ private MatchmakingSettings() public void Initialize() { Modes.Clear(); - string iniPath = ProgramConstants.GamePath + "INI/Matchmaking.ini"; - - if (!System.IO.File.Exists(iniPath)) + + // 1. 1v1 Mode + var mode1v1 = new MatchmakingModeDefinition { - CreateDefaultSettings(iniPath); - } - - var ini = new IniFile(iniPath); - List? modeSections = ini.GetSectionKeys("MatchmakingModes"); - - if (modeSections == null || modeSections.Count == 0) - return; - - foreach (string modeKey in modeSections) + UIName = "1v1", + PlayerCount = 2, + AlliedSideNames = new[] { "Allies", "Allied" }, + SovietSideNames = new[] { "Soviets", "Soviet" }, + AlliedColors = new[] { "Blue", "Green", "Cyan", "Yellow" }, + SovietColors = new[] { "Red", "Orange", "Purple", "Pink" }, + AssignTeams = false + }; + AddStandardForces(mode1v1); + Modes.Add(mode1v1); + + // 2. 2v2 Mode + var mode2v2 = new MatchmakingModeDefinition { - string sectionName = ini.GetStringValue("MatchmakingModes", modeKey, string.Empty); - - if (string.IsNullOrEmpty(sectionName)) - continue; - - var mode = new MatchmakingModeDefinition(); - mode.UIName = sectionName; - mode.PlayerCount = ini.GetIntValue(sectionName, "MaxPlayers", 2); - - // Enforce even players - if (mode.PlayerCount % 2 != 0) - mode.PlayerCount++; - - if (mode.PlayerCount > 8) - mode.PlayerCount = 8; - if (mode.PlayerCount < 2) - mode.PlayerCount = 2; - - string alliedSides = ini.GetStringValue(sectionName, "AlliedSides", "Allies,Allied"); - mode.AlliedSideNames = alliedSides.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - - string sovietSides = ini.GetStringValue(sectionName, "SovietSides", "Soviets,Soviet"); - mode.SovietSideNames = sovietSides.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - - string alliedColors = ini.GetStringValue(sectionName, "AlliedColors", "Blue,Cyan,Green,Yellow"); - mode.AlliedColors = alliedColors.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - - string sovietColors = ini.GetStringValue(sectionName, "SovietColors", "Red,Orange,Purple,Pink"); - mode.SovietColors = sovietColors.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - - mode.AssignTeams = ini.GetBooleanValue(sectionName, "AssignTeams", false); - - mode.ForceCheckboxes = new Dictionary(); - mode.ForceDropdowns = new Dictionary(); - - List? sectionKeys = ini.GetSectionKeys(sectionName); - - if (sectionKeys != null) - { - foreach (string key in sectionKeys) - { - if (key.StartsWith("chk", StringComparison.OrdinalIgnoreCase)) - { - mode.ForceCheckboxes[key] = ini.GetBooleanValue(sectionName, key, false); - } - else if (key.StartsWith("cmb", StringComparison.OrdinalIgnoreCase)) - { - mode.ForceDropdowns[key] = ini.GetStringValue(sectionName, key, string.Empty); - } - } - } - - Modes.Add(mode); - } + UIName = "2v2", + PlayerCount = 4, + AlliedSideNames = new[] { "Allies", "Allied" }, + SovietSideNames = new[] { "Soviets", "Soviet" }, + AlliedColors = new[] { "Blue", "Green", "Cyan", "Yellow" }, + SovietColors = new[] { "Red", "Orange", "Purple", "Pink" }, + AssignTeams = true + }; + AddStandardForces(mode2v2); + Modes.Add(mode2v2); + + // 3. 2v2v2v2 Mode + var mode2v2v2v2 = new MatchmakingModeDefinition + { + UIName = "2v2v2v2", + PlayerCount = 8, + AlliedSideNames = new[] { "Allies", "Allied" }, + SovietSideNames = new[] { "Soviets", "Soviet" }, + AlliedColors = new[] { "Blue", "Green", "Cyan", "Yellow" }, + SovietColors = new[] { "Red", "Orange", "Purple", "Pink" }, + AssignTeams = true + }; + AddStandardForces(mode2v2v2v2); + Modes.Add(mode2v2v2v2); } - private void CreateDefaultSettings(string path) + private void AddStandardForces(MatchmakingModeDefinition mode) { - try - { - string? directoryPath = System.IO.Path.GetDirectoryName(path); - - if (!string.IsNullOrEmpty(directoryPath)) - System.IO.Directory.CreateDirectory(directoryPath); - - var lines = new List - { - "[MatchmakingModes]", - "0=1v1", - "1=2v2v2v2", - "", - "[1v1]", - "MaxPlayers=2", - "AlliedSides=Allies,Allied", - "SovietSides=Soviets,Soviet", - "AlliedColors=Blue,Green,Cyan,Yellow", - "SovietColors=Red,Orange,Purple,Pink", - "chkShortGame=True", - "chkRedeplMCV=True", - "chkAutoRepair=False", - "chkMultiEng=False", - "chkIngameAllying=True", - "chkDestrBridges=True", - "chkBuildOffAlly=True", - "chkCrates=False", - "chkDisableGameSpeed=True", - "chkSuperWeapons=False", - "chkNoYuri=True", - "cmbCredits=10000", - "cmbStartingUnits=0", - "cmbGameSpeedCapMultiplayer=0", - "AssignTeams=False", - "", - "[2v2v2v2]", - "MaxPlayers=8", - "AlliedSides=Allies,Allied", - "SovietSides=Soviets,Soviet", - "AlliedColors=Blue,Green,Cyan,Yellow", - "SovietColors=Red,Orange,Purple,Pink", - "chkShortGame=True", - "chkRedeplMCV=True", - "chkAutoRepair=False", - "chkMultiEng=False", - "chkIngameAllying=True", - "chkDestrBridges=True", - "chkBuildOffAlly=True", - "chkCrates=False", - "chkDisableGameSpeed=True", - "chkSuperWeapons=False", - "chkNoYuri=True", - "cmbCredits=10000", - "cmbStartingUnits=0", - "cmbGameSpeedCapMultiplayer=0", - "AssignTeams=True" - }; - - System.IO.File.WriteAllLines(path, lines); - } - catch (Exception ex) - { - Logger.Log("Failed to create default Matchmaking.ini: " + ex.Message); - } + mode.ForceCheckboxes["chkShortGame"] = true; + mode.ForceCheckboxes["chkRedeplMCV"] = true; + mode.ForceCheckboxes["chkAutoRepair"] = false; + mode.ForceCheckboxes["chkMultiEng"] = false; + mode.ForceCheckboxes["chkIngameAllying"] = true; + mode.ForceCheckboxes["chkDestrBridges"] = true; + mode.ForceCheckboxes["chkBuildOffAlly"] = true; + mode.ForceCheckboxes["chkCrates"] = false; + mode.ForceCheckboxes["chkDisableGameSpeed"] = true; + mode.ForceCheckboxes["chkSuperWeapons"] = false; + mode.ForceCheckboxes["chkNoYuri"] = true; + + mode.ForceDropdowns["cmbCredits"] = "10000"; + mode.ForceDropdowns["cmbStartingUnits"] = "0"; + mode.ForceDropdowns["cmbGameSpeedCapMultiplayer"] = "0"; } } } diff --git a/INI/Matchmaking.ini b/INI/Matchmaking.ini deleted file mode 100644 index fc7e09e91..000000000 --- a/INI/Matchmaking.ini +++ /dev/null @@ -1,47 +0,0 @@ -[MatchmakingModes] -0=1v1 -1=2v2v2v2 - -[1v1] -MaxPlayers=2 -AlliedSides=Allies,Allied -SovietSides=Soviets,Soviet -AlliedColors=Blue,Green,Cyan,Yellow -SovietColors=Red,Orange,Purple,Pink -chkShortGame=True -chkRedeplMCV=True -chkAutoRepair=False -chkMultiEng=False -chkIngameAllying=True -chkDestrBridges=True -chkBuildOffAlly=True -chkCrates=False -chkDisableGameSpeed=True -chkSuperWeapons=False -chkNoYuri=True -cmbCredits=10000 -cmbStartingUnits=0 -cmbGameSpeedCapMultiplayer=0 -AssignTeams=False - -[2v2v2v2] -MaxPlayers=8 -AlliedSides=Allies,Allied -SovietSides=Soviets,Soviet -AlliedColors=Blue,Green,Cyan,Yellow -SovietColors=Red,Orange,Purple,Pink -chkShortGame=True -chkRedeplMCV=True -chkAutoRepair=False -chkMultiEng=False -chkIngameAllying=True -chkDestrBridges=True -chkBuildOffAlly=True -chkCrates=False -chkDisableGameSpeed=True -chkSuperWeapons=False -chkNoYuri=True -cmbCredits=10000 -cmbStartingUnits=0 -cmbGameSpeedCapMultiplayer=0 -AssignTeams=True diff --git a/INI/MatchmakingMaps.ini b/INI/MatchmakingMaps.ini deleted file mode 100644 index a6b3e4188..000000000 --- a/INI/MatchmakingMaps.ini +++ /dev/null @@ -1,6 +0,0 @@ -[1v1] -Map1=Blood Feud -Map2=May Day - -[2v2v2v2] -Map1=Invasion From 3806cd3d2499f8ecc4bf6ae42785386d0fca7952 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Mon, 13 Apr 2026 04:05:58 +0200 Subject: [PATCH 08/16] Remove custom logger and enforce SHA1 map matching --- .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 3 - .../Multiplayer/CnCNet/MatchmakingLogger.cs | 74 ------------------- .../CnCNet/MatchmakingMapDefinitions.cs | 18 ++--- .../Multiplayer/CnCNet/MatchmakingService.cs | 69 +++++++++-------- .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 38 +++------- 5 files changed, 59 insertions(+), 143 deletions(-) delete mode 100644 DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 9acb9be16..319d477df 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -153,7 +153,6 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private bool ctcpNoTunnelForGamesMessageShown = false; private MatchmakingService matchmakingService; - private MatchmakingLogger matchmakingLogger; private readonly HashSet hiddenMatchmakingChannels = new(StringComparer.OrdinalIgnoreCase); private readonly bool matchmakingAutoTestEnabled = string.Equals(Environment.GetEnvironmentVariable("MM_AUTOTEST"), "1", StringComparison.OrdinalIgnoreCase); @@ -199,7 +198,6 @@ public override void Initialize() BackgroundTexture = AssetLoader.LoadTexture("cncnetlobbybg.png"); localGameID = ClientConfiguration.Instance.LocalGame; localGame = gameCollection.GameList.Find(g => g.InternalName.ToUpper() == localGameID.ToUpper()); - matchmakingLogger = new MatchmakingLogger(() => ProgramConstants.PLAYERNAME); btnMatchmaking = new XNAClientButton(WindowManager); btnMatchmaking.Name = nameof(btnMatchmaking); @@ -408,7 +406,6 @@ public override void Initialize() matchmakingService = new MatchmakingService( random, () => ProgramConstants.PLAYERNAME, - matchmakingLogger, GetSelectedMatchmakingMode, CanJoinMatchmakingQueue, CanHostMatchmakingQueue, diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs deleted file mode 100644 index f63c51388..000000000 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingLogger.cs +++ /dev/null @@ -1,74 +0,0 @@ -#nullable enable - -using Rampastring.Tools; -using System; -using System.IO; - -namespace DTAClient.DXGUI.Multiplayer.CnCNet -{ - internal sealed class MatchmakingLogger - { - private readonly Func? localPlayerNameProvider; - private readonly string logFilePath; - private readonly object fileLock = new object(); - - public MatchmakingLogger(Func? localPlayerNameProvider) - { - this.localPlayerNameProvider = localPlayerNameProvider; - - // Log to Client/Logs/Matchmaking.log - string logDir = Path.Combine(ClientCore.ProgramConstants.GamePath, "Client", "Logs"); - if (!Directory.Exists(logDir)) - Directory.CreateDirectory(logDir); - - logFilePath = Path.Combine(logDir, "Matchmaking.log"); - - // Write a start session marker - LogToFile("--- NEW MATCHMAKING SESSION STARTED ---"); - } - - public void Info(string eventName, string? details = null) => - Log("INFO", eventName, details); - - public void Warn(string eventName, string? details = null) => - Log("WARN", eventName, details); - - public void Error(string eventName, Exception ex, string? details = null) => - Log("ERROR", eventName, $"{details} :: {ex}"); - - private void Log(string level, string eventName, string? details) - { - string formatted = Format(level, eventName, details); - - // Also log to the main client.log for redundancy - Logger.Log(formatted); - - // Log to our dedicated file - LogToFile(formatted); - } - - private void LogToFile(string message) - { - try - { - lock (fileLock) - { - string timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); - File.AppendAllText(logFilePath, $"[{timestamp}] {message}{Environment.NewLine}"); - } - } - catch { /* Ignore logging failures to prevent app crashes */ } - } - - private string Format(string level, string eventName, string? details) - { - string localPlayerName = localPlayerNameProvider?.Invoke() ?? string.Empty; - - if (string.IsNullOrEmpty(details)) - return $"[MM][{level}][{localPlayerName}] {eventName}"; - - return $"[MM][{level}][{localPlayerName}] {eventName} :: {details}"; - } - } -} - diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs index 5637d448d..949143114 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -14,25 +14,25 @@ public class MatchmakingMapDefinitions public static MatchmakingMapDefinitions Instance => instance ??= new MatchmakingMapDefinitions(); - public Dictionary> ModeMaps { get; private set; } + public Dictionary> ModeMapHashes { get; private set; } private MatchmakingMapDefinitions() { - ModeMaps = new Dictionary>(StringComparer.OrdinalIgnoreCase); + ModeMapHashes = new Dictionary>(StringComparer.OrdinalIgnoreCase); } public void Initialize() { - ModeMaps.Clear(); + ModeMapHashes.Clear(); - // 1v1 Maps - ModeMaps["1v1"] = new List { "Blood Feud", "May Day", "Dry Heat", "Arena Valley Extreme" }; + // 1v1 Map Hashes + ModeMapHashes["1v1"] = new List { "", "", "", "" }; - // 2v2 Maps - ModeMaps["2v2"] = new List { "Heck Freezes Over", "Tournament A" }; + // 2v2 Map Hashes + ModeMapHashes["2v2"] = new List { "", "" }; - // 2v2v2v2 Maps - ModeMaps["2v2v2v2"] = new List { "Invasion", "Snow Valley" }; + // 2v2v2v2 Map Hashes + ModeMapHashes["2v2v2v2"] = new List { "", "" }; } } } diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs index b9da335a0..dfe183900 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs @@ -16,7 +16,6 @@ internal sealed class MatchmakingService private const string CommandMatch = "MATCH"; private readonly Random random; - private readonly MatchmakingLogger logger; private readonly Func? selectedModeProvider; private readonly Func canJoinQueue; @@ -45,7 +44,6 @@ internal sealed class MatchmakingService public MatchmakingService( Random random, Func? localPlayerNameProvider, - MatchmakingLogger logger, Func? selectedModeProvider, Func canJoinQueue, Func canHostMatch, @@ -57,7 +55,6 @@ public MatchmakingService( { this.random = random; this.localPlayerNameProvider = localPlayerNameProvider; - this.logger = logger; this.selectedModeProvider = selectedModeProvider; this.canJoinQueue = canJoinQueue; this.canHostMatch = canHostMatch; @@ -72,17 +69,29 @@ public MatchmakingService( 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}"; + ClientCore.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) { - logger.Warn("ActionThrottled", $"cooldown_remaining={ActionCooldownMs - DateTime.Now.Subtract(lastActionTime).TotalMilliseconds}ms"); + Warn("ActionThrottled", $"cooldown_remaining={ActionCooldownMs - DateTime.Now.Subtract(lastActionTime).TotalMilliseconds}ms"); return; } if (isBusy) { - logger.Warn("ActionBlocked", "is_busy"); + Warn("ActionBlocked", "is_busy"); return; } @@ -106,7 +115,7 @@ public void StartQueue() if (!canJoinQueue()) { addNotice("Cannot join matchmaking queue while already joining or inside a game room."); - logger.Warn("QueueJoinRejected", "client_not_ready"); + Warn("QueueJoinRejected", "client_not_ready"); return; } @@ -114,7 +123,7 @@ public void StartQueue() if (string.IsNullOrEmpty(mode)) { - logger.Warn("QueueJoinRejected", "empty_mode"); + Warn("QueueJoinRejected", "empty_mode"); return; } @@ -122,7 +131,7 @@ public void StartQueue() if (string.IsNullOrEmpty(localPlayerName)) { - logger.Warn("QueueJoinRejected", "empty_local_player_name"); + Warn("QueueJoinRejected", "empty_local_player_name"); addNotice("Cannot join matchmaking queue: missing local player name."); return; } @@ -139,8 +148,8 @@ public void StartQueue() sendQueueCommand($"{CommandJoin};{mode};{queueTicket}"); addNotice($"Joined matchmaking queue ({mode})."); - logger.Info("QueueJoined", $"mode={mode}, ticket={queueTicket}"); - logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + Info("QueueJoined", $"mode={mode}, ticket={queueTicket}"); + Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); isBusy = false; TryClaimMatch(mode); @@ -153,7 +162,7 @@ public void LeaveQueue(bool broadcastLeave, bool showMessage) string? mode = queueMode; - logger.Info("QueueLeaveRequested", $"mode={mode}, broadcastLeave={broadcastLeave}"); + Info("QueueLeaveRequested", $"mode={mode}, broadcastLeave={broadcastLeave}"); isBusy = true; if (broadcastLeave) @@ -180,7 +189,7 @@ public void LeaveQueue(bool broadcastLeave, bool showMessage) public void Reset() { - logger.Info("ResetState"); + Info("ResetState"); queues.Clear(); handledMatchIds.Clear(); pendingClaimIds.Clear(); @@ -197,7 +206,7 @@ public void HandleChannelCommand(string sender, string commandData) if (parts.Length == 0) return; - logger.Info("QueueCommandReceived", $"sender={sender}, payload={commandData}"); + Info("QueueCommandReceived", $"sender={sender}, payload={commandData}"); switch (parts[0]) { @@ -214,7 +223,7 @@ public void HandleChannelCommand(string sender, string commandData) HandleMatchClaim(sender, parts[1], parts[2], parts[3]); return; default: - logger.Warn("QueueCommandIgnored", $"sender={sender}, payload={commandData}"); + Warn("QueueCommandIgnored", $"sender={sender}, payload={commandData}"); return; } } @@ -224,7 +233,7 @@ public void HandleUserLeftOrQuit(string playerName) if (string.IsNullOrEmpty(playerName)) return; - logger.Info("UserLeftQueueChannels", $"player={playerName}"); + Info("UserLeftQueueChannels", $"player={playerName}"); RemovePlayerFromAllQueues(playerName); @@ -241,8 +250,8 @@ private void HandleQueueJoin(string sender, string mode, string ticket) AddOrUpdateQueueEntry(sender, mode, ticket); - logger.Info("QueueJoinApplied", $"sender={sender}, mode={mode}, ticket={ticket}"); - logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + Info("QueueJoinApplied", $"sender={sender}, mode={mode}, ticket={ticket}"); + Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); TryClaimMatch(mode); } @@ -254,8 +263,8 @@ private void HandleQueueLeave(string sender, string mode) RemoveQueueEntryFromMode(sender, mode); - logger.Info("QueueLeaveApplied", $"sender={sender}, mode={mode}"); - logger.Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); + Info("QueueLeaveApplied", $"sender={sender}, mode={mode}"); + Info("QueueSnapshot", $"mode={mode}, players={GetQueueSnapshot(mode)}"); if (string.Equals(sender, LocalPlayerName, StringComparison.OrdinalIgnoreCase)) ClearQueueState(updateUiState: true); @@ -272,7 +281,7 @@ private void HandleMatchClaim(string sender, string mode, string matchId, string if (!handledMatchIds.Add(matchId)) { - logger.Info("MatchClaimIgnored", $"reason=already_handled, matchId={matchId}"); + Info("MatchClaimIgnored", $"reason=already_handled, matchId={matchId}"); return; } @@ -283,7 +292,7 @@ private void HandleMatchClaim(string sender, string mode, string matchId, string if (participants.Count != requiredPlayersForMode(mode)) { - logger.Warn("MatchClaimIgnored", $"reason=invalid_count, matchId={matchId}, mode={mode}, participants={participants.Count}"); + Warn("MatchClaimIgnored", $"reason=invalid_count, matchId={matchId}, mode={mode}, participants={participants.Count}"); return; } @@ -293,13 +302,13 @@ private void HandleMatchClaim(string sender, string mode, string matchId, string if (!participants.Contains(sender, StringComparer.OrdinalIgnoreCase)) { - logger.Warn("MatchClaimIgnored", $"reason=sender_not_in_match, sender={sender}, matchId={matchId}"); + Warn("MatchClaimIgnored", $"reason=sender_not_in_match, sender={sender}, matchId={matchId}"); return; } string designatedHost = participants[0]; - logger.Info("MatchClaimAccepted", $"matchId={matchId}, mode={mode}, sender={sender}, host={designatedHost}, participants={string.Join(",", participants)}"); + Info("MatchClaimAccepted", $"matchId={matchId}, mode={mode}, sender={sender}, host={designatedHost}, participants={string.Join(",", participants)}"); RemovePlayersFromQueues(participants); @@ -315,7 +324,7 @@ private void HandleMatchClaim(string sender, string mode, string matchId, string } else if (localPlayerInMatch) { - logger.Info("MatchClaimAwaitingHost", $"matchId={matchId}, mode={mode}, expectedHost={designatedHost}"); + Info("MatchClaimAwaitingHost", $"matchId={matchId}, mode={mode}, expectedHost={designatedHost}"); } TryClaimMatch(mode); @@ -328,7 +337,7 @@ private void TryClaimMatch(string mode) if (!queues.TryGetValue(mode, out List? queue)) { - logger.Info("MatchClaimSkipped", $"mode={mode}, reason=queue_missing"); + Info("MatchClaimSkipped", $"mode={mode}, reason=queue_missing"); return; } @@ -336,7 +345,7 @@ private void TryClaimMatch(string mode) if (queue == null || queue.Count < requiredPlayers) { - logger.Info("MatchClaimWaiting", $"mode={mode}, queued={queue?.Count ?? 0}, required={requiredPlayers}"); + Info("MatchClaimWaiting", $"mode={mode}, queued={queue?.Count ?? 0}, required={requiredPlayers}"); return; } @@ -349,13 +358,13 @@ private void TryClaimMatch(string mode) if (!participants.Any(p => string.Equals(p.PlayerName, localPlayerName, StringComparison.OrdinalIgnoreCase))) { - logger.Info("MatchClaimSkipped", $"mode={mode}, reason=local_not_in_participants, local={localPlayerName}, participants={string.Join(",", participants.Select(p => p.PlayerName))}"); + Info("MatchClaimSkipped", $"mode={mode}, reason=local_not_in_participants, local={localPlayerName}, participants={string.Join(",", participants.Select(p => p.PlayerName))}"); return; } if (!canHostMatch()) { - logger.Info("MatchClaimSkipped", $"mode={mode}, reason=cannot_host_now, local={localPlayerName}"); + Info("MatchClaimSkipped", $"mode={mode}, reason=cannot_host_now, local={localPlayerName}"); return; } @@ -363,7 +372,7 @@ private void TryClaimMatch(string mode) if (handledMatchIds.Contains(matchId) || pendingClaimIds.Contains(matchId)) { - logger.Info("MatchClaimSkipped", $"mode={mode}, reason=already_pending_or_handled, matchId={matchId}"); + Info("MatchClaimSkipped", $"mode={mode}, reason=already_pending_or_handled, matchId={matchId}"); return; } @@ -371,7 +380,7 @@ private void TryClaimMatch(string mode) string participantList = string.Join(",", participants.Select(p => p.PlayerName)); - logger.Info("MatchClaimBroadcast", $"matchId={matchId}, mode={mode}, sender={localPlayerName}, participants={participantList}"); + Info("MatchClaimBroadcast", $"matchId={matchId}, mode={mode}, sender={localPlayerName}, participants={participantList}"); sendQueueCommand($"{CommandMatch};{mode};{matchId};{participantList}"); diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 317d5575f..bb33b1e40 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -2571,57 +2571,41 @@ private void ApplyMatchmakingOptionPreset(string mode) Logger.Log($"[Matchmaking] Processing map selection for mode '{mode}' (req players: {reqPlayers})."); // 1. Try to find maps from the defined list in MatchmakingMaps.ini - if (MatchmakingMapDefinitions.Instance.ModeMaps.TryGetValue(mode, out List definedMapNames) && definedMapNames.Count > 0) + if (MatchmakingMapDefinitions.Instance.ModeMapHashes.TryGetValue(mode, out List definedMapHashes) && definedMapHashes.Count > 0) { - Logger.Log($"[Matchmaking] Looking for maps in defined INI list: {string.Join(", ", definedMapNames)}"); + Logger.Log($"[Matchmaking] Looking for maps in defined INI list by Hash: {string.Join(", ", definedMapHashes)}"); foreach (GameModeMap m in GameModeMaps) { - if (m.Map == null) + if (m.Map == null || string.IsNullOrEmpty(m.Map.SHA1)) continue; - // Flexible matching: check exact name, untranslated name, base file path, - // and also try stripping the "[2]" style player count prefix/suffix often found in RA2 map names. - bool nameMatched = definedMapNames.Any(dmn => - { - string target = dmn.Trim(); - - // Exact matches - if (string.Equals(m.Map.Name, target, StringComparison.OrdinalIgnoreCase) || - string.Equals(m.Map.UntranslatedName, target, StringComparison.OrdinalIgnoreCase) || - (m.Map.BaseFilePath != null && string.Equals(m.Map.BaseFilePath, target, StringComparison.OrdinalIgnoreCase))) - return true; - - // Fuzzy match: Strip "[2] " prefix or similar from both and compare - string cleanMapName = StripMapPrefix(m.Map.Name); - string cleanTarget = StripMapPrefix(target); - - return string.Equals(cleanMapName, cleanTarget, StringComparison.OrdinalIgnoreCase); - }); - - if (nameMatched) + // 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 and player count ({m.Map.MaxPlayers})."); + 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 but has {m.Map.MaxPlayers} players (Mode needs {reqPlayers}). skipping."); + 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 maps defined in MatchmakingMaps.ini for mode '{mode}'."); + 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 names were: {string.Join(", ", definedMapNames ?? new List())})"); + 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(); From 6654dd190d1502b3d169321124d945ec17fda541 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Mon, 13 Apr 2026 06:18:30 +0200 Subject: [PATCH 09/16] use standard logging and dynamic INI for map hashes --- .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 74 +++++------ .../CnCNet/MatchmakingMapDefinitions.cs | 42 ++++++- .../Multiplayer/CnCNet/MatchmakingService.cs | 2 +- .../Multiplayer/CnCNet/MatchmakingSettings.cs | 116 ++++++++++-------- .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 15 ++- 5 files changed, 142 insertions(+), 107 deletions(-) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 319d477df..61113f195 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -931,7 +931,7 @@ private void BtnMatchmaking_LeftClick(object sender, EventArgs e) { if (DateTime.Now - lastMatchmakingClickTime < TimeSpan.FromSeconds(2)) { - matchmakingLogger?.Info("BtnClickThrottled", "Click ignored due to 2s cooldown"); + Logger.Log($"[Matchmaking] { "BtnClickThrottled" }: { "Click ignored due to 2s cooldown" }"); return; } @@ -939,7 +939,7 @@ private void BtnMatchmaking_LeftClick(object sender, EventArgs e) if (gameLobby.Enabled) { - matchmakingLogger?.Info("BtnClickLeavingLobby", "Leaving lobby before matchmaking toggle"); + Logger.Log($"[Matchmaking] { "BtnClickLeavingLobby" }: { "Leaving lobby before matchmaking toggle" }"); gameLobby.LeaveGameLobby(); isInGameRoom = false; } @@ -966,7 +966,7 @@ private bool CanJoinMatchmakingQueue() { if (isInGameRoom || gameLobby.Enabled || gameLoadingLobby.Enabled || isJoiningGame || ProgramConstants.IsInGame) { - matchmakingLogger?.Info("CannotJoinQueue", $"isInGameRoom={isInGameRoom}, gameLobby={gameLobby.Enabled}, gameLoadingLobby={gameLoadingLobby.Enabled}, isJoiningGame={isJoiningGame}, IsInGame={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) @@ -1153,12 +1153,12 @@ private void MatchmakingChatChannel_UserAdded(object sender, ChannelUserEventArg if (CanJoinMatchmakingQueue()) { matchmakingAutoTestStarted = true; - matchmakingLogger?.Info("AutoTestJoinQueue", "mode=1v1"); + Logger.Log($"[Matchmaking] { "AutoTestJoinQueue" }: { "mode=1v1" }"); matchmakingService?.ToggleQueue(); } else { - matchmakingLogger?.Warn("AutoTestJoinQueueSkipped", "reason=client_not_ready"); + Logger.Log($"[Matchmaking] WARNING { "AutoTestJoinQueueSkipped" }: { "reason=client_not_ready" }"); } } @@ -1181,7 +1181,7 @@ private void TryCreateMatchmakingRoom(string mode, List participants) CnCNetTunnel selectedTunnel = tunnelHandler.CurrentTunnel ?? tunnelHandler.Tunnels?.FirstOrDefault(); if (selectedTunnel == null) { - matchmakingLogger?.Warn("CreateRoomFailed", "reason=no_tunnel"); + Logger.Log($"[Matchmaking] WARNING { "CreateRoomFailed" }: { "reason=no_tunnel" }"); AddMainChannelNotice("Matchmaking failed: no tunnel server is available."); return; } @@ -1195,8 +1195,7 @@ private void TryCreateMatchmakingRoom(string mode, List participants) .ToList(); pendingMatchmakingMode = mode; - matchmakingLogger?.Info("CreateRoomRequested", - $"mode={mode}, maxPlayers={maxPlayers}, participants={string.Join(",", participants)}"); + Logger.Log($"[Matchmaking] { "CreateRoomRequested" }: { $"mode={mode}, maxPlayers={maxPlayers}, participants={string.Join(",", participants)}" }"); string previousCreatedChannelName = lastCreatedGameChannelName; Gcw_GameCreated(this, new GameCreationEventArgs(roomName, maxPlayers, string.Empty, selectedTunnel, 0)); @@ -1206,14 +1205,14 @@ private void TryCreateMatchmakingRoom(string mode, List participants) { pendingMatchmakingParticipants = null; pendingMatchmakingMode = null; - matchmakingLogger?.Warn("CreateRoomFailed", "reason=no_new_channel_created"); + Logger.Log($"[Matchmaking] WARNING { "CreateRoomFailed" }: { "reason=no_new_channel_created" }"); AddMainChannelNotice("Matchmaking failed: unable to create a room."); return; } } catch (Exception ex) { - matchmakingLogger?.Error("CreateRoomException", ex); + Logger.Log($"[Matchmaking] ERROR { "CreateRoomException" }: { ex }"); AddMainChannelNotice("Matchmaking failed: unexpected error while creating room."); } } @@ -1233,16 +1232,14 @@ private void TrySendMatchmakingParticipantsToRoom(string hostName) string commandParameters = $"{pendingMatchmakingMode};{lastCreatedGameChannelName};{lastCreatedGameRoomName};{lastCreatedGamePassword};{lastCreatedGameMaxPlayers};{lastCreatedGameSkillLevel};{lastCreatedGameTunnel.Address};{lastCreatedGameTunnel.Port}"; - matchmakingLogger?.Info("InvitePrepared", - $"mode={pendingMatchmakingMode}, host={hostName}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, participants={string.Join(",", pendingMatchmakingParticipants)}, messageType={MatchmakingInviteMessageType}, priority={MatchmakingInviteMessagePriority}"); + 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)) { - matchmakingLogger?.Info("InviteSkippedHost", - $"mode={pendingMatchmakingMode}, host={hostName}, participant={participant}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}"); + Logger.Log($"[Matchmaking] { "InviteSkippedHost" }: { $"mode={pendingMatchmakingMode}, host={hostName}, participant={participant}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}" }"); continue; } @@ -1250,12 +1247,10 @@ private void TrySendMatchmakingParticipantsToRoom(string hostName) $"PRIVMSG {participant} :\u0001{MatchmakingService.PrivateJoinCommandName} {commandParameters}\u0001", MatchmakingInviteMessageType, MatchmakingInviteMessagePriority)); - matchmakingLogger?.Info("InviteSent", - $"mode={pendingMatchmakingMode}, host={hostName}, participant={participant}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, messageType={MatchmakingInviteMessageType}, priority={MatchmakingInviteMessagePriority}"); + Logger.Log($"[Matchmaking] { "InviteSent" }: { $"mode={pendingMatchmakingMode}, host={hostName}, participant={participant}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, messageType={MatchmakingInviteMessageType}, priority={MatchmakingInviteMessagePriority}" }"); } - matchmakingLogger?.Info("InvitesSent", - $"mode={pendingMatchmakingMode}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, participants={string.Join(",", pendingMatchmakingParticipants)}"); + Logger.Log($"[Matchmaking] { "InvitesSent" }: { $"mode={pendingMatchmakingMode}, room={lastCreatedGameRoomName}, channel={lastCreatedGameChannelName}, participants={string.Join(",", pendingMatchmakingParticipants)}" }"); AddMainChannelNotice($"Match found ({pendingMatchmakingMode}). Room created on {hostName}."); @@ -1270,7 +1265,7 @@ private void SendMatchmakingChannelCommand(string payload) if (matchmakingChannel == null) { - matchmakingLogger?.Warn("QueueCommandDropped", $"reason=missing_channel,payload={payload}"); + Logger.Log($"[Matchmaking] WARNING { "QueueCommandDropped" }: { $"reason=missing_channel,payload={payload}" }"); return; } @@ -1278,8 +1273,7 @@ private void SendMatchmakingChannelCommand(string payload) $"{MatchmakingService.ChannelCommandName} {payload}", MatchmakingQueueMessageType, MatchmakingQueueMessagePriority); - matchmakingLogger?.Info("QueueCommandSent", - $"channel={matchmakingChannel.ChannelName}, payload={payload}, messageType={MatchmakingQueueMessageType}, priority={MatchmakingQueueMessagePriority}"); + Logger.Log($"[Matchmaking] { "QueueCommandSent" }: { $"channel={matchmakingChannel.ChannelName}, payload={payload}, messageType={MatchmakingQueueMessageType}, priority={MatchmakingQueueMessagePriority}" }"); } private static bool IsLikelyMatchmakingRoomName(string roomName) @@ -1326,8 +1320,7 @@ private void TrackHiddenMatchmakingRoom(string channelName, string roomName, str if (removedAny) SortAndRefreshHostedGames(); - matchmakingLogger?.Info("HiddenRoomTracked", - $"mode={mode}, channel={channelName}, room={roomName}"); + Logger.Log($"[Matchmaking] { "HiddenRoomTracked" }: { $"mode={mode}, channel={channelName}, room={roomName}" }"); } private void HandleMatchmakingRoomInvitation(string sender, string argumentsString) @@ -1336,13 +1329,13 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri { if (!CanReceiveInvitationMessagesFrom(sender)) { - matchmakingLogger?.Info("JoinInvitationIgnored", $"reason=sender_not_allowed,sender={sender}"); + Logger.Log($"[Matchmaking] { "JoinInvitationIgnored" }: { $"reason=sender_not_allowed,sender={sender}" }"); return; } if (isInGameRoom || gameLobby.Enabled || gameLoadingLobby.Enabled || isJoiningGame || ProgramConstants.IsInGame) { - matchmakingLogger?.Info("JoinInvitationIgnored", $"reason=already_in_room_or_joining,sender={sender}"); + Logger.Log($"[Matchmaking] { "JoinInvitationIgnored" }: { $"reason=already_in_room_or_joining,sender={sender}" }"); return; } @@ -1368,20 +1361,19 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri TrackHiddenMatchmakingRoom(channelName, roomName, mode); - matchmakingLogger?.Info("InviteReceived", - $"sender={sender}, mode={mode}, room={roomName}, channel={channelName}"); + Logger.Log($"[Matchmaking] { "InviteReceived" }: { $"sender={sender}, mode={mode}, room={roomName}, channel={channelName}" }"); CnCNetTunnel tunnel = FindTunnelByAddressAndPort(tunnelAddress, tunnelPort); if (tunnel == null) { - matchmakingLogger?.Warn("JoinInvitationRejected", $"reason=tunnel_unavailable,address={tunnelAddress},port={tunnelPort}"); + Logger.Log($"[Matchmaking] WARNING { "JoinInvitationRejected" }: { $"reason=tunnel_unavailable,address={tunnelAddress},port={tunnelPort}" }"); AddMainChannelNotice($"Matchmaking failed: tunnel {tunnelAddress}:{tunnelPort} is unavailable."); return; } if (localGame == null) { - matchmakingLogger?.Warn("JoinInvitationRejected", "reason=missing_local_game"); + Logger.Log($"[Matchmaking] WARNING { "JoinInvitationRejected" }: { "reason=missing_local_game" }"); AddMainChannelNotice("Matchmaking failed: local game definition is missing."); return; } @@ -1413,16 +1405,13 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri Incompatible = false }; - matchmakingLogger?.Info("JoinInvitationAccepted", - $"sender={sender}, mode={mode}, channel={channelName}, room={roomName}"); + Logger.Log($"[Matchmaking] { "JoinInvitationAccepted" }: { $"sender={sender}, mode={mode}, channel={channelName}, room={roomName}" }"); - matchmakingLogger?.Info("JoinGameStarted", - $"source=matchmaking_invite, host={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); - matchmakingLogger?.Info("JoinGameResult", - $"source=matchmaking_invite, host={sender}, mode={mode}, channel={channelName}, room={roomName}, success={joinStarted}"); + Logger.Log($"[Matchmaking] { "JoinGameResult" }: { $"source=matchmaking_invite, host={sender}, mode={mode}, channel={channelName}, room={roomName}, success={joinStarted}" }"); if (!joinStarted) { @@ -1432,7 +1421,7 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri } catch (Exception ex) { - matchmakingLogger?.Error("JoinInvitationException", ex); + Logger.Log($"[Matchmaking] ERROR { "JoinInvitationException" }: { ex }"); AddMainChannelNotice("Matchmaking failed: unexpected error while joining room."); } } @@ -1679,13 +1668,12 @@ private void GameChannel_UserAdded(object sender, Online.ChannelUserEventArgs e) if (!string.IsNullOrEmpty(pendingMatchmakingMode)) { bool presetApplied = gameLobby.ApplyMatchmakingHostPreset(pendingMatchmakingMode); - matchmakingLogger?.Info("MatchmakingPresetApplied", - $"mode={pendingMatchmakingMode}, success={presetApplied}"); + Logger.Log($"[Matchmaking] { "MatchmakingPresetApplied" }: { $"mode={pendingMatchmakingMode}, success={presetApplied}" }"); } isInGameRoom = true; SetLogOutButtonText(); - matchmakingLogger?.Info("JoinedGameRoom", $"channel={gameChannel.ChannelName}, room={gameChannel.UIName}"); + Logger.Log($"[Matchmaking] { "JoinedGameRoom" }: { $"channel={gameChannel.ChannelName}, room={gameChannel.UIName}" }"); TrySendMatchmakingParticipantsToRoom(ProgramConstants.PLAYERNAME); } } @@ -1758,8 +1746,7 @@ private void Gcw_GameCreated(object sender, GameCreationEventArgs e) lastCreatedGameMaxPlayers = e.MaxPlayers; lastCreatedGameSkillLevel = e.SkillLevel; - matchmakingLogger?.Info("RoomCreated", - $"channel={channelName}, room={e.GameRoomName}, maxPlayers={e.MaxPlayers}, tunnel={e.Tunnel?.Address}:{e.Tunnel?.Port}"); + 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); @@ -1931,7 +1918,7 @@ private void ConnectionManager_WelcomeMessageReceived(object sender, EventArgs e CnCNetGameCheck.Instance.InitializeService(gameCheckCancellation); if (matchmakingAutoTestEnabled) - matchmakingLogger?.Info("AutoTestEnabled", "MM_AUTOTEST=1"); + Logger.Log($"[Matchmaking] { "AutoTestEnabled" }: { "MM_AUTOTEST=1" }"); } private void ConnectionManager_PrivateCTCPReceived(object sender, PrivateCTCPEventArgs e) @@ -2292,8 +2279,7 @@ private void GameBroadcastChannel_CTCPReceived(object sender, ChannelCTCPEventAr SortAndRefreshHostedGames(); } - matchmakingLogger?.Info("HiddenRoomFiltered", - $"host={e.UserName}, channel={gameRoomChannelName}, room={gameRoomDisplayName}"); + Logger.Log($"[Matchmaking] { "HiddenRoomFiltered" }: { $"host={e.UserName}, channel={gameRoomChannelName}, room={gameRoomDisplayName}" }"); return; } diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs index 949143114..1ba8b5091 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -25,14 +25,44 @@ public void Initialize() { ModeMapHashes.Clear(); - // 1v1 Map Hashes - ModeMapHashes["1v1"] = new List { "", "", "", "" }; + 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; + } - // 2v2 Map Hashes - ModeMapHashes["2v2"] = new List { "", "" }; + 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; + } - // 2v2v2v2 Map Hashes - ModeMapHashes["2v2v2v2"] = new List { "", "" }; + foreach (string section in sections) + { + var keys = ini.GetSectionKeys(section); + if (keys != null && keys.Count > 0) + { + List mapHashes = new List(); + foreach (string key in keys) + { + string hash = ini.GetStringValue(section, key, string.Empty); + if (!string.IsNullOrEmpty(hash)) + { + mapHashes.Add(hash); + } + } + if (mapHashes.Count > 0) + { + ModeMapHashes[section] = mapHashes; + Logger.Log($"[Matchmaking] Loaded map pool for mode [{section}] with {mapHashes.Count} maps."); + } + } + } } } } diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs index dfe183900..129ab46e1 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs @@ -75,7 +75,7 @@ private void Log(string level, string eventName, string? details = null) string formatted = string.IsNullOrEmpty(details) ? $"[MM][{level}][{localPlayerName}] {eventName}" : $"[MM][{level}][{localPlayerName}] {eventName} :: {details}"; - ClientCore.Logger.Log(formatted); + Rampastring.Tools.Logger.Log(formatted); } private void Info(string eventName, string? details = null) => Log("INFO", eventName, details); diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs index db9c657f9..1f68f629e 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs @@ -38,66 +38,76 @@ public void Initialize() { Modes.Clear(); - // 1. 1v1 Mode - var mode1v1 = new MatchmakingModeDefinition + string iniPath = SafePath.CombineFilePath(ProgramConstants.GamePath, "INI", "Matchmaking.ini"); + var fileInfo = SafePath.GetFile(iniPath); + if (!fileInfo.Exists) { - UIName = "1v1", - PlayerCount = 2, - AlliedSideNames = new[] { "Allies", "Allied" }, - SovietSideNames = new[] { "Soviets", "Soviet" }, - AlliedColors = new[] { "Blue", "Green", "Cyan", "Yellow" }, - SovietColors = new[] { "Red", "Orange", "Purple", "Pink" }, - AssignTeams = false - }; - AddStandardForces(mode1v1); - Modes.Add(mode1v1); + Logger.Log($"[Matchmaking] Warning: Configuration file not found at {iniPath}. Matchmaking modes will be empty."); + return; + } - // 2. 2v2 Mode - var mode2v2 = new MatchmakingModeDefinition + IniFile ini = new IniFile(iniPath); + List modeKeys = ini.GetSectionKeys("MatchmakingModes"); + + if (modeKeys == null || modeKeys.Count == 0) { - UIName = "2v2", - PlayerCount = 4, - AlliedSideNames = new[] { "Allies", "Allied" }, - SovietSideNames = new[] { "Soviets", "Soviet" }, - AlliedColors = new[] { "Blue", "Green", "Cyan", "Yellow" }, - SovietColors = new[] { "Red", "Orange", "Purple", "Pink" }, - AssignTeams = true - }; - AddStandardForces(mode2v2); - Modes.Add(mode2v2); + Logger.Log($"[Matchmaking] Warning: No modes defined in [MatchmakingModes] section of {iniPath}."); + return; + } - // 3. 2v2v2v2 Mode - var mode2v2v2v2 = new MatchmakingModeDefinition + foreach (string key in modeKeys) { - UIName = "2v2v2v2", - PlayerCount = 8, - AlliedSideNames = new[] { "Allies", "Allied" }, - SovietSideNames = new[] { "Soviets", "Soviet" }, - AlliedColors = new[] { "Blue", "Green", "Cyan", "Yellow" }, - SovietColors = new[] { "Red", "Orange", "Purple", "Pink" }, - AssignTeams = true - }; - AddStandardForces(mode2v2v2v2); - Modes.Add(mode2v2v2v2); - } + 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(); - private void AddStandardForces(MatchmakingModeDefinition mode) - { - mode.ForceCheckboxes["chkShortGame"] = true; - mode.ForceCheckboxes["chkRedeplMCV"] = true; - mode.ForceCheckboxes["chkAutoRepair"] = false; - mode.ForceCheckboxes["chkMultiEng"] = false; - mode.ForceCheckboxes["chkIngameAllying"] = true; - mode.ForceCheckboxes["chkDestrBridges"] = true; - mode.ForceCheckboxes["chkBuildOffAlly"] = true; - mode.ForceCheckboxes["chkCrates"] = false; - mode.ForceCheckboxes["chkDisableGameSpeed"] = true; - mode.ForceCheckboxes["chkSuperWeapons"] = false; - mode.ForceCheckboxes["chkNoYuri"] = true; + // 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); + } + } - mode.ForceDropdowns["cmbCredits"] = "10000"; - mode.ForceDropdowns["cmbStartingUnits"] = "0"; - mode.ForceDropdowns["cmbGameSpeedCapMultiplayer"] = "0"; + // 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 bb33b1e40..4faf6614c 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -2553,10 +2553,19 @@ private void ApplyMatchmakingOptionPreset(string mode) foreach (KeyValuePair kvp in def.ForceDropdowns) { - if (int.TryParse(kvp.Value, out int idx)) + // Prioritize matching by Text (e.g. "10000") over Index + GameLobbyDropDown dd = FindDropDown(kvp.Key); + if (dd == null) 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 - SetDropDownValueByText(kvp.Key, kvp.Value); + } } // Matchmaking Random Map Selection (Strictly Backend Filtered) From df4ea022245f25053ef09890533673242a98531e Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Wed, 15 Apr 2026 20:42:58 +0200 Subject: [PATCH 10/16] Implement MatchFoundWindow and refine matchmaking sync UI and hide the room and auto launch --- DXMainClient/DXGUI/GameClass.cs | 1 + DXMainClient/DXGUI/Generic/MainMenu.cs | 4 + .../DXGUI/Generic/MatchFoundWindow.cs | 77 +++++++++++++++++++ .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 14 +++- .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 66 +++++++++------- 5 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 DXMainClient/DXGUI/Generic/MatchFoundWindow.cs 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/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 0d054d111..48e0ef569 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -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,6 +50,7 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, this.gameCollection = gameCollection; this.cncnetUserData = cncnetUserData; this.optionsWindow = optionsWindow; + this.matchFoundWindow = matchFoundWindow; this.mapLoader = mapLoader; this.random = random; @@ -62,11 +64,11 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, 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; @@ -125,6 +127,7 @@ public CnCNetLobby(WindowManager windowManager, CnCNetManager connectionManager, private PrivateMessagingWindow pmWindow; + private XNAPanel passwordRequestWindowPanel; private PasswordRequestWindow passwordRequestWindow; private bool isInGameRoom = false; @@ -1198,6 +1201,7 @@ private void TryCreateMatchmakingRoom(string mode, List participants) Logger.Log($"[Matchmaking] { "CreateRoomRequested" }: { $"mode={mode}, maxPlayers={maxPlayers}, participants={string.Join(",", participants)}" }"); string previousCreatedChannelName = lastCreatedGameChannelName; + matchFoundWindow.Show(); Gcw_GameCreated(this, new GameCreationEventArgs(roomName, maxPlayers, string.Empty, selectedTunnel, 0)); if (pendingMatchmakingParticipants != null && @@ -1383,6 +1387,8 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri AddMainChannelNotice($"Match found ({mode}). Joining room..."); + matchFoundWindow.Show(); + HostedCnCNetGame hostedGame = new HostedCnCNetGame( channelName, ProgramConstants.CNCNET_PROTOCOL_REVISION, @@ -1415,6 +1421,7 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri if (!joinStarted) { + matchFoundWindow.Hide(); gameLobby.SetMatchmakingMode(null); AddMainChannelNotice("Matchmaking failed: unable to join the created room."); } @@ -1435,6 +1442,7 @@ private void ResetMatchmakingState() { pendingMatchmakingParticipants = null; pendingMatchmakingMode = null; + matchFoundWindow.Hide(); matchmakingService?.Reset(); } diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 4faf6614c..66dcc488e 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -349,9 +349,12 @@ public void OnJoined() channel.SendCTCPMessage("FHSH " + gameFilesHash, QueuedMessageType.SYSTEM_MESSAGE, 10); } - TopBar.AddPrimarySwitchable(this); - TopBar.SwitchToPrimary(); - WindowManager.SelectedControl = tbChatInput; + if (!IsHiddenMatchmakingRoom()) + { + TopBar.AddPrimarySwitchable(this); + TopBar.SwitchToPrimary(); + WindowManager.SelectedControl = tbChatInput; + } ResetAutoReadyCheckbox(); if (!IsHost && IsHiddenMatchmakingRoom()) @@ -599,6 +602,7 @@ 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; @@ -1508,6 +1512,8 @@ protected override void HandleMapUpdated(Map updatedMap, string previousSHA1) /// protected override void GameProcessExited() { + ResetGameState(); + if (!string.IsNullOrEmpty(matchmakingPresetMode)) { Logger.Log($"MatchmakingGameExited: Auto-leaving room. mode={matchmakingPresetMode}"); @@ -1515,8 +1521,6 @@ protected override void GameProcessExited() return; } - ResetGameState(); - if (IsHiddenMatchmakingRoom()) { TopBar.AddPrimarySwitchable(this); @@ -1648,21 +1652,17 @@ protected override void StartGame() if (IsHiddenMatchmakingRoom()) { - TopBar.AddPrimarySwitchable(this); - - if (IsHost) + _ = Task.Run(async () => { - AddNotice("Matchmaking: Auto-leaving room in 60 seconds...".L10N("Client:Main:AutoLeaveNotice")); - _ = Task.Run(async () => + await Task.Delay(3000); + if (!string.IsNullOrEmpty(matchmakingPresetMode)) { - await Task.Delay(60000); - if (Enabled && !string.IsNullOrEmpty(matchmakingPresetMode)) - { - Logger.Log("Matchmaking: 60s timeout reached, leaving room."); + Logger.Log("Matchmaking: Game started, explicitly leaving the lobby like pressing Game Lobby button."); + WindowManager.AddCallback(new Action(() => { LeaveGameLobby(); - } - }); - } + }), null); + } + }); } } @@ -1988,21 +1988,33 @@ protected override bool UpdateLaunchGameButtonStatus() autoLaunchCancellation = new System.Threading.CancellationTokenSource(); var token = autoLaunchCancellation.Token; - Logger.Log("Auto-Launching matchmaking game in 10 seconds..."); - /* + Logger.Log("Starting matchmaking auto-launch loop..."); System.Threading.Tasks.Task.Run(async () => { try { - await System.Threading.Tasks.Task.Delay(10000, token); - WindowManager.AddCallback(new Action(() => { - Logger.Log("Auto-Launching matchmaking game now!"); - HostLaunchGame(); - }), null); + 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 countdown cancelled."); + Logger.Log("Auto-Launch loop cancelled."); } }); - */ - Logger.Log("Auto-Launch disabled by user requested comment."); } else if (!isReady && autoLaunchStarted) { From f8fc6e2291e9df5feb8e933afa6a3abe084c8992 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Fri, 17 Apr 2026 03:03:57 +0200 Subject: [PATCH 11/16] Add debugMode and enbale auto allying and clear game options when create room with matchmaking --- .../DXGUI/Generic/GameSessionDropDown.cs | 3 +- .../DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs | 12 +- .../CnCNet/MatchmakingMapDefinitions.cs | 55 ++++-- .../Multiplayer/CnCNet/MatchmakingMapEntry.cs | 24 +++ .../Multiplayer/CnCNet/MatchmakingSettings.cs | 5 + .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 156 ++++++++++++++++-- .../Multiplayer/GameLobby/GameLobbyBase.cs | 22 ++- 7 files changed, 249 insertions(+), 28 deletions(-) create mode 100644 DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs 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/Multiplayer/CnCNet/CnCNetLobby.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs index 48e0ef569..431d44ade 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLobby.cs @@ -398,6 +398,12 @@ public override void Initialize() 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 }); @@ -1201,7 +1207,8 @@ private void TryCreateMatchmakingRoom(string mode, List participants) Logger.Log($"[Matchmaking] { "CreateRoomRequested" }: { $"mode={mode}, maxPlayers={maxPlayers}, participants={string.Join(",", participants)}" }"); string previousCreatedChannelName = lastCreatedGameChannelName; - matchFoundWindow.Show(); + if (!MatchmakingSettings.Instance.DebugMode) + matchFoundWindow.Show(); Gcw_GameCreated(this, new GameCreationEventArgs(roomName, maxPlayers, string.Empty, selectedTunnel, 0)); if (pendingMatchmakingParticipants != null && @@ -1387,7 +1394,8 @@ private void HandleMatchmakingRoomInvitation(string sender, string argumentsStri AddMainChannelNotice($"Match found ({mode}). Joining room..."); - matchFoundWindow.Show(); + if (!MatchmakingSettings.Instance.DebugMode) + matchFoundWindow.Show(); HostedCnCNetGame hostedGame = new HostedCnCNetGame( channelName, diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs index 1ba8b5091..eabb4066a 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -14,16 +14,16 @@ public class MatchmakingMapDefinitions public static MatchmakingMapDefinitions Instance => instance ??= new MatchmakingMapDefinitions(); - public Dictionary> ModeMapHashes { get; private set; } + public Dictionary> ModeMapEntries { get; private set; } private MatchmakingMapDefinitions() { - ModeMapHashes = new Dictionary>(StringComparer.OrdinalIgnoreCase); + ModeMapEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase); } public void Initialize() { - ModeMapHashes.Clear(); + ModeMapEntries.Clear(); string iniPath = SafePath.CombineFilePath(ProgramConstants.GamePath, "INI", "MatchmakingMaps.ini"); var fileInfo = SafePath.GetFile(iniPath); @@ -47,19 +47,54 @@ public void Initialize() var keys = ini.GetSectionKeys(section); if (keys != null && keys.Count > 0) { - List mapHashes = new List(); + List entries = new List(); foreach (string key in keys) { - string hash = ini.GetStringValue(section, key, string.Empty); - if (!string.IsNullOrEmpty(hash)) + 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++) { - mapHashes.Add(hash); + 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(); + } + } + } } + Logger.Log($"[Matchmaking] Loaded map entry for SHA1 {entry.SHA1} with {entry.TeamSpawns.Count} team mappings from raw: {rawValue}"); + entries.Add(entry); } - if (mapHashes.Count > 0) + if (entries.Count > 0) { - ModeMapHashes[section] = mapHashes; - Logger.Log($"[Matchmaking] Loaded map pool for mode [{section}] with {mapHashes.Count} maps."); + 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..1aa3732a9 --- /dev/null +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs @@ -0,0 +1,24 @@ +#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 MatchmakingMapEntry(string sha1) + { + SHA1 = sha1; + } + + public bool HasForcedSpawns => TeamSpawns.Count > 0; + } +} diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs index 1f68f629e..68ca785e7 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs @@ -26,6 +26,8 @@ public class MatchmakingSettings private static MatchmakingSettings? instance; public static MatchmakingSettings Instance => instance ??= new MatchmakingSettings(); + + public bool DebugMode => true; public List Modes { get; private set; } @@ -47,6 +49,9 @@ public void Initialize() } 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) diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 66dcc488e..1977ae267 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -349,7 +349,7 @@ public void OnJoined() channel.SendCTCPMessage("FHSH " + gameFilesHash, QueuedMessageType.SYSTEM_MESSAGE, 10); } - if (!IsHiddenMatchmakingRoom()) + if (!IsHiddenMatchmakingRoom() || MatchmakingSettings.Instance.DebugMode) { TopBar.AddPrimarySwitchable(this); TopBar.SwitchToPrimary(); @@ -368,8 +368,11 @@ public void OnJoined() RequestPlayerOptions(oppositeSide, oppositeColor, 0, 0); } - chkAutoReady.Enable(); - chkAutoReady.Checked = true; + if (!MatchmakingSettings.Instance.DebugMode) + { + chkAutoReady.Enable(); + chkAutoReady.Checked = true; + } } UpdatePing(); @@ -803,7 +806,9 @@ private void Channel_UserAdded(object sender, ChannelUserEventArgs e) if (matchmakingPresetMode != null) { - ApplyMatchmakingFactionColorPreset(broadcastChanges: false); + // broadcastChanges: true ensures that when a new player joins, + // the host sends them their assigned team and color immediately. + ApplyMatchmakingFactionColorPreset(broadcastChanges: true); } BroadcastPlayerOptions(); @@ -1514,7 +1519,7 @@ protected override void GameProcessExited() { ResetGameState(); - if (!string.IsNullOrEmpty(matchmakingPresetMode)) + if (!string.IsNullOrEmpty(matchmakingPresetMode) && !MatchmakingSettings.Instance.DebugMode) { Logger.Log($"MatchmakingGameExited: Auto-leaving room. mode={matchmakingPresetMode}"); LeaveGameLobby(); @@ -1676,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) @@ -1981,7 +2013,7 @@ protected override bool UpdateLaunchGameButtonStatus() ApplyMatchmakingFactionColorPreset(broadcastChanges: true); } - if (isReady && !autoLaunchStarted) + if (isReady && !autoLaunchStarted && !MatchmakingSettings.Instance.DebugMode) { autoLaunchStarted = true; // // StartAutoLaunchCountdown(); // Disabled temporarily by user request // Disabled temporarily by user request @@ -2496,9 +2528,62 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) 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 && 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]; @@ -2508,8 +2593,10 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) int colorIndex = ResolveColorIndex(preferredColorNames, i, usedColorIndices); int teamId = 0; - if (canAssignTeams && maximumTeamId > 0) - teamId = Math.Min((i / 2) + 1, maximumTeamId); + if (canAssignTeams && maximumTeamId > 0 && teamSize > 0) + { + teamId = Math.Min((i / teamSize) + 1, maximumTeamId); + } bool playerChanged = false; if (playerInfo.SideId != sideIndex) @@ -2524,10 +2611,25 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) playerChanged = true; } - if (playerInfo.TeamId != teamId) + // 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)) { - playerInfo.TeamId = teamId; - playerChanged = true; + 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) @@ -2552,6 +2654,7 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) private void ApplyMatchmakingOptionPreset(string mode) { + ResetGameOptionsToDefaults(); MatchmakingModeDefinition def = MatchmakingSettings.Instance.Modes.FirstOrDefault(m => string.Equals(m.UIName, mode, StringComparison.OrdinalIgnoreCase)); if (def == null) @@ -2560,14 +2663,24 @@ private void ApplyMatchmakingOptionPreset(string mode) 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) continue; + 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) @@ -2578,6 +2691,10 @@ private void ApplyMatchmakingOptionPreset(string mode) { 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) @@ -2592,8 +2709,10 @@ private void ApplyMatchmakingOptionPreset(string mode) Logger.Log($"[Matchmaking] Processing map selection for mode '{mode}' (req players: {reqPlayers})."); // 1. Try to find maps from the defined list in MatchmakingMaps.ini - if (MatchmakingMapDefinitions.Instance.ModeMapHashes.TryGetValue(mode, out List definedMapHashes) && definedMapHashes.Count > 0) + 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) @@ -2672,7 +2791,10 @@ 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; @@ -2702,7 +2824,13 @@ private void SetDropDownValueByText(string dropDownName, string text) private void SetDropDownValueByIndex(string dropDownName, int index) { GameLobbyDropDown dropDown = FindDropDown(dropDownName); - if (dropDown == null || index < 0 || index >= dropDown.Items.Count) + 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; 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; /// From 5cef504bfc5606c9ea510846546cdd39ac052405 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Fri, 17 Apr 2026 03:56:26 +0200 Subject: [PATCH 12/16] Make DeugMode false --- DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs index 68ca785e7..427d8f04b 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingSettings.cs @@ -27,7 +27,7 @@ public class MatchmakingSettings public static MatchmakingSettings Instance => instance ??= new MatchmakingSettings(); - public bool DebugMode => true; + public bool DebugMode => false; public List Modes { get; private set; } From efd3ea1afd951061dc7675a4d1485bd13805831b Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Sat, 18 Apr 2026 01:29:06 +0200 Subject: [PATCH 13/16] Select game mode from INI --- .../CnCNet/MatchmakingMapDefinitions.cs | 7 ++++- .../Multiplayer/CnCNet/MatchmakingMapEntry.cs | 2 ++ .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs index eabb4066a..4a021323a 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapDefinitions.cs @@ -87,8 +87,13 @@ public void Initialize() } } } + 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 from raw: {rawValue}"); + 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) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs index 1aa3732a9..75ce1c565 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingMapEntry.cs @@ -14,6 +14,8 @@ public class MatchmakingMapEntry /// public Dictionary TeamSpawns { get; set; } = new Dictionary(); + public string? GameMode { get; set; } + public MatchmakingMapEntry(string sha1) { SHA1 = sha1; diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 1977ae267..895b321a3 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -2540,6 +2540,37 @@ public bool ApplyMatchmakingFactionColorPreset(bool broadcastChanges) 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(); From 9b079b40065057846e5c65896f478c1180447cc7 Mon Sep 17 00:00:00 2001 From: IDRXGamer Date: Sat, 18 Apr 2026 02:33:03 +0200 Subject: [PATCH 14/16] implement MatchmakingService for handling random host --- .../Multiplayer/CnCNet/MatchmakingService.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs index 129ab46e1..55e8e5ead 100644 --- a/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs +++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/MatchmakingService.cs @@ -297,7 +297,7 @@ private void HandleMatchClaim(string sender, string mode, string matchId, string } participants = participants - .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .OrderBy(p => GetDeterministicHash(matchId + p)) .ToList(); if (!participants.Contains(sender, StringComparer.OrdinalIgnoreCase)) @@ -343,6 +343,12 @@ private void TryClaimMatch(string mode) 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}"); @@ -350,7 +356,7 @@ private void TryClaimMatch(string mode) } List participants = queue - .OrderBy(qe => qe.PlayerName, StringComparer.OrdinalIgnoreCase) + .OrderBy(qe => GetDeterministicHash(qe.Ticket + qe.PlayerName)) .Take(requiredPlayers) .ToList(); @@ -458,6 +464,19 @@ private void ClearQueueState(bool 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; From 2e11bd3b1934ea3fb72636e58702dc27da47b0d6 Mon Sep 17 00:00:00 2001 From: 11EJDE11 Date: Sat, 18 Apr 2026 16:50:06 +1200 Subject: [PATCH 15/16] Fix spelling of "Metallic" --- DXMainClient/Online/CnCNetManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DXMainClient/Online/CnCNetManager.cs b/DXMainClient/Online/CnCNetManager.cs index b3f5bfddc..70dd90b03 100644 --- a/DXMainClient/Online/CnCNetManager.cs +++ b/DXMainClient/Online/CnCNetManager.cs @@ -72,7 +72,7 @@ public CnCNetManager(WindowManager wm, GameCollection gc, CnCNetUserData cncNetU new IRCColor("Sky Blue".L10N("Client:Main:ColorSkyBlue"), true, Color.LightSkyBlue, 11), new IRCColor("Blue".L10N("Client:Main:ColorBlue"), true, Color.RoyalBlue, 12), new IRCColor("Pink".L10N("Client:Main:ColorPink"), true, Color.DeepPink, 13), - new IRCColor("Metalic".L10N("Client:Main:ColorLightGrayMetalic"), true, Color.LightGray, 14), + new IRCColor("Metallic".L10N("Client:Main:ColorLightGrayMetalic"), true, Color.LightGray, 14), new IRCColor("Gray".L10N("Client:Main:ColorGray"), false, Color.Gray, 15) }; } From 456189f3d5bdbe004b58a601f6f28452772c8c2d Mon Sep 17 00:00:00 2001 From: 11EJDE11 Date: Sat, 18 Apr 2026 16:57:19 +1200 Subject: [PATCH 16/16] Fix spelling of "Metallic" --- DXMainClient/DXGUI/Multiplayer/LANLobby.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DXMainClient/DXGUI/Multiplayer/LANLobby.cs b/DXMainClient/DXGUI/Multiplayer/LANLobby.cs index 524982648..d7098f643 100644 --- a/DXMainClient/DXGUI/Multiplayer/LANLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/LANLobby.cs @@ -195,7 +195,7 @@ public override void Initialize() chatColors = new LANColor[] { new LANColor("Gray".L10N("Client:Main:ColorGray"), Color.Gray), - new LANColor("Metalic".L10N("Client:Main:ColorLightGrayMetalic"), Color.LightGray), + new LANColor("Metallic".L10N("Client:Main:ColorLightGrayMetalic"), Color.LightGray), new LANColor("Green".L10N("Client:Main:ColorGreen"), Color.ForestGreen), new LANColor("Lime Green".L10N("Client:Main:ColorLimeGreen"), Color.LimeGreen), new LANColor("Green Yellow".L10N("Client:Main:ColorGreenYellow"), Color.GreenYellow),