diff --git a/ClientCore/ClientConfiguration.cs b/ClientCore/ClientConfiguration.cs
index 574c14e0c..5b8997eba 100644
--- a/ClientCore/ClientConfiguration.cs
+++ b/ClientCore/ClientConfiguration.cs
@@ -252,6 +252,8 @@ public string GetThemePath(string themeName)
public string MPMapsIniPath => SafePath.CombineFilePath(clientDefinitionsIni.GetStringValue(SETTINGS, "MPMapsPath", SafePath.CombineFilePath("INI", "MPMaps.ini")));
+ public string QuickMatchPath => clientDefinitionsIni.GetStringValue(SETTINGS, "QuickMatchPath", "INI/QuickMatch.ini");
+
public string KeyboardINI => clientDefinitionsIni.GetStringValue(SETTINGS, "KeyboardINI", "Keyboard.ini");
public int MinimumIngameWidth => clientDefinitionsIni.GetIntValue(SETTINGS, "MinimumIngameWidth", 640);
diff --git a/ClientCore/Exceptions/ClientException.cs b/ClientCore/Exceptions/ClientException.cs
new file mode 100644
index 000000000..dbcaf3b5a
--- /dev/null
+++ b/ClientCore/Exceptions/ClientException.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace ClientCore.Exceptions
+{
+ public class ClientException : Exception
+ {
+ public ClientException(string message, Exception innerException = null) : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/ClientCore/Exceptions/ClientRequestException.cs b/ClientCore/Exceptions/ClientRequestException.cs
new file mode 100644
index 000000000..552e34d66
--- /dev/null
+++ b/ClientCore/Exceptions/ClientRequestException.cs
@@ -0,0 +1,14 @@
+using System.Net;
+
+namespace ClientCore.Exceptions
+{
+ public class ClientRequestException : ClientException
+ {
+ public HttpStatusCode? StatusCode { get; }
+
+ public ClientRequestException(string message, HttpStatusCode? statusCode = null) : base(message)
+ {
+ StatusCode = statusCode;
+ }
+ }
+}
diff --git a/ClientGUI/Parser.cs b/ClientGUI/Parser.cs
index bd23ca5b3..a91a46f0b 100644
--- a/ClientGUI/Parser.cs
+++ b/ClientGUI/Parser.cs
@@ -24,6 +24,7 @@
using Rampastring.XNAUI.XNAControls;
using System;
using System.Collections.Generic;
+using System.Text.RegularExpressions;
namespace ClientGUI
{
@@ -69,6 +70,9 @@ private XNAControl GetControl(string controlName)
if (controlName == primaryControl.Name)
return primaryControl;
+ if (controlName == primaryControl.Parent.Name)
+ return primaryControl.Parent;
+
var control = Find(primaryControl.Children, controlName);
if (control == null)
throw new KeyNotFoundException($"Control '{controlName}' not found while parsing input '{Input}'");
@@ -104,7 +108,7 @@ public void SetPrimaryControl(XNAControl primaryControl)
public int GetExprValue(string input, XNAControl parsingControl)
{
this.parsingControl = parsingControl;
- Input = input;
+ Input = Regex.Replace(input, @"\s", "");
tokenPlace = 0;
return GetExprValue();
}
@@ -115,8 +119,6 @@ private int GetExprValue()
while (true)
{
- SkipWhitespace();
-
if (IsEndOfInput())
return value;
diff --git a/ClientGUI/XNAMessageBox.cs b/ClientGUI/XNAMessageBox.cs
index 06351c588..c4afa1b0d 100644
--- a/ClientGUI/XNAMessageBox.cs
+++ b/ClientGUI/XNAMessageBox.cs
@@ -262,7 +262,10 @@ private static void MsgBox_OKClicked(XNAMessageBox messageBox)
/// The caption of the message box.
/// The description in the message box.
/// The XNAMessageBox instance that is created.
- public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string caption, string description)
+ public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string caption, string description)
+ => ShowYesNoDialog(windowManager, caption, description, null);
+
+ public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string caption, string description, Action yesAction)
{
var panel = new DarkeningPanel(windowManager);
windowManager.AddAndInitializeControl(panel);
@@ -274,6 +277,8 @@ public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string
panel.AddChild(msgBox);
msgBox.YesClickedAction = MsgBox_YesClicked;
+ if (yesAction != null)
+ msgBox.YesClickedAction += yesAction;
msgBox.NoClickedAction = MsgBox_NoClicked;
return msgBox;
diff --git a/ClientGUI/XNAScrollablePanel.cs b/ClientGUI/XNAScrollablePanel.cs
new file mode 100644
index 000000000..d9dc190ff
--- /dev/null
+++ b/ClientGUI/XNAScrollablePanel.cs
@@ -0,0 +1,12 @@
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace ClientGUI;
+
+public class XNAScrollablePanel : XNAPanel
+{
+ public XNAScrollablePanel(WindowManager windowManager) : base(windowManager)
+ {
+ DrawMode = ControlDrawMode.UNIQUE_RENDER_TARGET;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Generic/LoadingScreen.cs b/DXMainClient/DXGUI/Generic/LoadingScreen.cs
index 357eb8c8c..508cd1434 100644
--- a/DXMainClient/DXGUI/Generic/LoadingScreen.cs
+++ b/DXMainClient/DXGUI/Generic/LoadingScreen.cs
@@ -7,6 +7,7 @@
using DTAClient.DXGUI.Multiplayer;
using DTAClient.DXGUI.Multiplayer.CnCNet;
using DTAClient.DXGUI.Multiplayer.GameLobby;
+using DTAClient.DXGUI.Multiplayer.QuickMatch;
using DTAClient.Online;
using DTAConfig;
using Microsoft.Xna.Framework;
@@ -14,6 +15,7 @@
using System.Threading.Tasks;
using Rampastring.Tools;
using ClientUpdater;
+using Rampastring.XNAUI.XNAControls;
using SkirmishLobby = DTAClient.DXGUI.Multiplayer.GameLobby.SkirmishLobby;
namespace DTAClient.DXGUI.Generic
@@ -79,26 +81,20 @@ private void LogGameClientVersion()
private void LoadMaps()
{
- mapLoader = new MapLoader();
+ mapLoader = MapLoader.GetInstance();
mapLoader.LoadMaps();
}
private void Finish()
{
- ProgramConstants.GAME_VERSION = ClientConfiguration.Instance.ModMode ?
+ ProgramConstants.GAME_VERSION = ClientConfiguration.Instance.ModMode ?
"N/A" : Updater.GameVersion;
DiscordHandler discordHandler = null;
if (!string.IsNullOrEmpty(ClientConfiguration.Instance.DiscordAppId))
discordHandler = new DiscordHandler(WindowManager);
- ClientGUICreator.Instance.AddControl(typeof(GameLobbyCheckBox));
- ClientGUICreator.Instance.AddControl(typeof(GameLobbyDropDown));
- ClientGUICreator.Instance.AddControl(typeof(MapPreviewBox));
- ClientGUICreator.Instance.AddControl(typeof(GameLaunchButton));
- ClientGUICreator.Instance.AddControl(typeof(ChatListBox));
- ClientGUICreator.Instance.AddControl(typeof(XNAChatTextBox));
- ClientGUICreator.Instance.AddControl(typeof(PlayerExtraOptionsPanel));
+ DeclareCustomControls();
var gameCollection = new GameCollection();
gameCollection.Initialize();
@@ -109,7 +105,7 @@ private void Finish()
var cncnetManager = new CnCNetManager(WindowManager, gameCollection, cncnetUserData);
var tunnelHandler = new TunnelHandler(WindowManager, cncnetManager);
var privateMessageHandler = new PrivateMessageHandler(cncnetManager, cncnetUserData);
-
+
var topBar = new TopBar(WindowManager, cncnetManager, privateMessageHandler);
var optionsWindow = new OptionsWindow(WindowManager, gameCollection, topBar);
@@ -120,23 +116,26 @@ private void Finish()
var cncnetGameLobby = new CnCNetGameLobby(WindowManager,
"MultiplayerGameLobby", topBar, cncnetManager, tunnelHandler, gameCollection, cncnetUserData, mapLoader, discordHandler, pmWindow);
- var cncnetGameLoadingLobby = new CnCNetGameLoadingLobby(WindowManager,
+ var cncnetGameLoadingLobby = new CnCNetGameLoadingLobby(WindowManager,
topBar, cncnetManager, tunnelHandler, mapLoader.GameModes, gameCollection, discordHandler);
- var cncnetLobby = new CnCNetLobby(WindowManager, cncnetManager,
+ var cncnetLobby = new CnCNetLobby(WindowManager, cncnetManager,
cncnetGameLobby, cncnetGameLoadingLobby, topBar, pmWindow, tunnelHandler,
gameCollection, cncnetUserData, optionsWindow);
var gipw = new GameInProgressWindow(WindowManager);
var skirmishLobby = new SkirmishLobby(WindowManager, topBar, mapLoader, discordHandler);
+ var quickMatchWindow = new QuickMatchWindow(WindowManager);
topBar.SetSecondarySwitch(cncnetLobby);
- var mainMenu = new MainMenu(WindowManager, skirmishLobby, lanLobby,
+ var mainMenu = new MainMenu(WindowManager, skirmishLobby, quickMatchWindow, lanLobby,
topBar, optionsWindow, cncnetLobby, cncnetManager, discordHandler);
WindowManager.AddAndInitializeControl(mainMenu);
DarkeningPanel.AddAndInitializeWithControl(WindowManager, skirmishLobby);
+ DarkeningPanel.AddAndInitializeWithControl(WindowManager, quickMatchWindow);
+
DarkeningPanel.AddAndInitializeWithControl(WindowManager, cncnetGameLoadingLobby);
DarkeningPanel.AddAndInitializeWithControl(WindowManager, cncnetGameLobby);
@@ -152,9 +151,11 @@ private void Finish()
topBar.SetTertiarySwitch(pmWindow);
topBar.SetOptionsWindow(optionsWindow);
+ topBar.SetQuickMatchWindow(quickMatchWindow);
WindowManager.AddAndInitializeControl(gipw);
skirmishLobby.Disable();
+ quickMatchWindow.Disable();
cncnetLobby.Disable();
cncnetGameLobby.Disable();
cncnetGameLoadingLobby.Disable();
@@ -183,6 +184,26 @@ private void Finish()
Cursor.Visible = visibleSpriteCursor;
}
+ private static void DeclareCustomControls()
+ {
+ ClientGUICreator.Instance.AddControl(typeof(GameLobbyCheckBox));
+ ClientGUICreator.Instance.AddControl(typeof(GameLobbyDropDown));
+ ClientGUICreator.Instance.AddControl(typeof(MapPreviewBox));
+ ClientGUICreator.Instance.AddControl(typeof(GameLaunchButton));
+ ClientGUICreator.Instance.AddControl(typeof(ChatListBox));
+ ClientGUICreator.Instance.AddControl(typeof(XNAChatTextBox));
+ ClientGUICreator.Instance.AddControl(typeof(XNAScrollBar));
+ ClientGUICreator.Instance.AddControl(typeof(XNAPasswordBox));
+ ClientGUICreator.Instance.AddControl(typeof(PlayerExtraOptionsPanel));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchLoginPanel));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchLobbyPanel));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchLobbyFooterPanel));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchMapList));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchStatusOverlay));
+ ClientGUICreator.Instance.AddControl(typeof(XNAClientTabControl));
+ ClientGUICreator.Instance.AddControl(typeof(XNAScrollablePanel));
+ }
+
public override void Update(GameTime gameTime)
{
base.Update(gameTime);
diff --git a/DXMainClient/DXGUI/Generic/MainMenu.cs b/DXMainClient/DXGUI/Generic/MainMenu.cs
index b890e5bb8..3fb8470bc 100644
--- a/DXMainClient/DXGUI/Generic/MainMenu.cs
+++ b/DXMainClient/DXGUI/Generic/MainMenu.cs
@@ -5,6 +5,7 @@
using DTAClient.DXGUI.Multiplayer;
using DTAClient.DXGUI.Multiplayer.CnCNet;
using DTAClient.DXGUI.Multiplayer.GameLobby;
+using DTAClient.DXGUI.Multiplayer.QuickMatch;
using DTAClient.Online;
using DTAConfig;
using Localization;
@@ -36,12 +37,20 @@ class MainMenu : XNAWindow, ISwitchable
///
/// Creates a new instance of the main menu.
///
- public MainMenu(WindowManager windowManager, SkirmishLobby skirmishLobby,
- LANLobby lanLobby, TopBar topBar, OptionsWindow optionsWindow,
+ public MainMenu(
+ WindowManager windowManager,
+ SkirmishLobby skirmishLobby,
+ QuickMatchWindow quickMatchWindow,
+ LANLobby lanLobby,
+ TopBar topBar,
+ OptionsWindow optionsWindow,
CnCNetLobby cncnetLobby,
- CnCNetManager connectionManager, DiscordHandler discordHandler) : base(windowManager)
+ CnCNetManager connectionManager,
+ DiscordHandler discordHandler
+ ) : base(windowManager)
{
this.skirmishLobby = skirmishLobby;
+ this.quickMatchWindow = quickMatchWindow;
this.lanLobby = lanLobby;
this.topBar = topBar;
this.connectionManager = connectionManager;
@@ -62,6 +71,8 @@ public MainMenu(WindowManager windowManager, SkirmishLobby skirmishLobby,
private SkirmishLobby skirmishLobby;
+ private QuickMatchWindow quickMatchWindow;
+
private LANLobby lanLobby;
private CnCNetManager connectionManager;
@@ -106,6 +117,7 @@ private bool UpdateInProgress
private XNAClientButton btnLoadGame;
private XNAClientButton btnSkirmish;
private XNAClientButton btnCnCNet;
+ private XNAClientButton btnQuickmatch;
private XNAClientButton btnLan;
private XNAClientButton btnOptions;
private XNAClientButton btnMapEditor;
@@ -154,6 +166,11 @@ public override void Initialize()
btnCnCNet.HoverSoundEffect = new EnhancedSoundEffect("MainMenu/button.wav");
btnCnCNet.LeftClick += BtnCnCNet_LeftClick;
+ btnQuickmatch = new XNAClientButton(WindowManager);
+ btnQuickmatch.Name = nameof(btnQuickmatch);
+ btnQuickmatch.LeftClick += BtnQuickmatch_LeftClick;
+ btnQuickmatch.Disable();
+
btnLan = new XNAClientButton(WindowManager);
btnLan.Name = nameof(btnLan);
btnLan.IdleTexture = AssetLoader.LoadTexture("MainMenu/lan.png");
@@ -225,6 +242,7 @@ public override void Initialize()
AddChild(btnLoadGame);
AddChild(btnSkirmish);
AddChild(btnCnCNet);
+ AddChild(btnQuickmatch);
AddChild(btnLan);
AddChild(btnOptions);
AddChild(btnMapEditor);
@@ -799,6 +817,8 @@ private void BtnLan_LeftClick(object sender, EventArgs e)
private void BtnCnCNet_LeftClick(object sender, EventArgs e) => topBar.SwitchToSecondary();
+ private void BtnQuickmatch_LeftClick(object sender, EventArgs e) => quickMatchWindow.Enable();
+
private void BtnSkirmish_LeftClick(object sender, EventArgs e)
{
skirmishLobby.Open();
diff --git a/DXMainClient/DXGUI/Generic/TopBar.cs b/DXMainClient/DXGUI/Generic/TopBar.cs
index ab8fadcc6..370fb6756 100644
--- a/DXMainClient/DXGUI/Generic/TopBar.cs
+++ b/DXMainClient/DXGUI/Generic/TopBar.cs
@@ -10,6 +10,7 @@
using ClientCore;
using System.Threading;
using DTAClient.Domain.Multiplayer.CnCNet;
+using DTAClient.DXGUI.Multiplayer.QuickMatch;
using DTAClient.Online.EventArguments;
using DTAConfig;
using Localization;
@@ -53,6 +54,7 @@ PrivateMessageHandler privateMessageHandler
private ISwitchable privateMessageSwitch;
private OptionsWindow optionsWindow;
+ private QuickMatchWindow quickMatchWindow;
private XNAClientButton btnMainButton;
private XNAClientButton btnCnCNetLobby;
@@ -107,6 +109,8 @@ public void SetOptionsWindow(OptionsWindow optionsWindow)
optionsWindow.EnabledChanged += OptionsWindow_EnabledChanged;
}
+ public void SetQuickMatchWindow(QuickMatchWindow quickMatchWindow) => this.quickMatchWindow = quickMatchWindow;
+
private void OptionsWindow_EnabledChanged(object sender, EventArgs e)
{
if (!lanMode)
@@ -324,6 +328,7 @@ private void BtnMainButton_LeftClick(object sender, EventArgs e)
LastSwitchType = SwitchType.PRIMARY;
cncnetLobbySwitch.SwitchOff();
privateMessageSwitch.SwitchOff();
+ quickMatchWindow.Disable();
primarySwitches[primarySwitches.Count - 1].SwitchOn();
// HACK warning
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyFooterPanel.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyFooterPanel.cs
new file mode 100644
index 000000000..05c30a199
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyFooterPanel.cs
@@ -0,0 +1,37 @@
+using System;
+using ClientGUI;
+using Rampastring.XNAUI;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch;
+
+public class QuickMatchLobbyFooterPanel : INItializableWindow
+{
+ private XNAClientButton btnQuickMatch;
+ private XNAClientButton btnLogout;
+ private XNAClientButton btnExit;
+
+ public EventHandler LogoutEvent;
+
+ public EventHandler ExitEvent;
+
+ public EventHandler QuickMatchEvent;
+
+ public QuickMatchLobbyFooterPanel(WindowManager windowManager) : base(windowManager)
+ {
+ }
+
+ public override void Initialize()
+ {
+ IniNameOverride = nameof(QuickMatchLobbyFooterPanel);
+ base.Initialize();
+
+ btnLogout = FindChild(nameof(btnLogout));
+ btnLogout.LeftClick += (_, _) => LogoutEvent?.Invoke(this, null);
+
+ btnExit = FindChild(nameof(btnExit));
+ btnExit.LeftClick += (_, _) => ExitEvent?.Invoke(this, null);
+
+ btnQuickMatch = FindChild(nameof(btnQuickMatch));
+ btnQuickMatch.LeftClick += (_, _) => QuickMatchEvent?.Invoke(this, null);
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyPanel.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyPanel.cs
new file mode 100644
index 000000000..8c10437f1
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyPanel.cs
@@ -0,0 +1,399 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+using Localization;
+using Rampastring.Tools;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+using Timer = System.Timers.Timer;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchLobbyPanel : INItializableWindow
+ {
+ private readonly QmService qmService;
+ private readonly MapLoader mapLoader;
+
+ public event EventHandler Exit;
+ public event EventHandler LogoutEvent;
+
+ private QuickMatchMapList mapList;
+ private QuickMatchLobbyFooterPanel footerPanel;
+ private XNAClientDropDown ddUserAccounts;
+ private XNAClientDropDown ddNicknames;
+ private XNAClientDropDown ddSides;
+ private XNAPanel mapPreviewBox;
+ private XNAPanel settingsPanel;
+ private XNAClientButton btnMap;
+ private XNAClientButton btnSettings;
+ private XNALabel lblStats;
+
+ private readonly EnhancedSoundEffect matchFoundSoundEffect;
+ private readonly QmSettings qmSettings;
+
+ public QuickMatchLobbyPanel(WindowManager windowManager) : base(windowManager)
+ {
+ qmService = QmService.GetInstance();
+ qmService.QmEvent += HandleQmEvent;
+
+ mapLoader = MapLoader.GetInstance();
+
+ qmSettings = QmSettingsService.GetInstance().GetSettings();
+ matchFoundSoundEffect = new EnhancedSoundEffect(qmSettings.MatchFoundSoundFile);
+
+ IniNameOverride = nameof(QuickMatchLobbyPanel);
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ mapList = FindChild(nameof(mapList));
+ mapList.MapSelectedEvent += HandleMapSelectedEventEvent;
+
+ footerPanel = FindChild(nameof(footerPanel));
+ footerPanel.ExitEvent += (sender, args) => Exit?.Invoke(sender, args);
+ footerPanel.LogoutEvent += BtnLogout_LeftClick;
+ footerPanel.QuickMatchEvent += BtnQuickMatch_LeftClick;
+
+ ddUserAccounts = FindChild(nameof(ddUserAccounts));
+ ddUserAccounts.SelectedIndexChanged += UserAccountSelected;
+
+ ddNicknames = FindChild(nameof(ddNicknames));
+
+ ddSides = FindChild(nameof(ddSides));
+
+ mapPreviewBox = FindChild(nameof(mapPreviewBox));
+ mapPreviewBox.PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.CENTERED;
+
+ settingsPanel = FindChild(nameof(settingsPanel));
+
+ btnMap = FindChild(nameof(btnMap));
+ btnMap.LeftClick += (_, _) => EnableRightPanel(mapPreviewBox);
+
+ btnSettings = FindChild(nameof(btnSettings));
+ btnSettings.LeftClick += (_, _) => EnableRightPanel(settingsPanel);
+
+ lblStats = FindChild(nameof(lblStats));
+
+ EnabledChanged += EnabledChangedEvent;
+ }
+
+ private void HandleQmEvent(object sender, QmEvent qmEvent)
+ {
+ switch (qmEvent)
+ {
+ case QmLadderMapsEvent e:
+ HandleLadderMapsEvent(e.LadderMaps);
+ return;
+ case QmLadderStatsEvent e:
+ HandleLadderStatsEvent(e.LadderStats);
+ return;
+ case QmLoadingLadderStatsEvent:
+ HandleLoadingLadderStatsEvent();
+ return;
+ case QmRequestResponseEvent e:
+ HandleQuickMatchRequestResponseEvent(e.Response);
+ return;
+ case QmLaddersAndUserAccountsEvent e:
+ HandleLoadLadderAndUserAccountsEvent(e);
+ return;
+ case QmLadderSelectedEvent e:
+ HandleLadderSelectedEvent(e.Ladder);
+ return;
+ case QmLoginEvent:
+ Enable();
+ return;
+ case QmLogoutEvent:
+ HandleLogoutEvent();
+ return;
+ }
+ }
+
+ private void EnableRightPanel(XNAControl control)
+ {
+ foreach (XNAControl parentChild in control.Parent.Children)
+ parentChild.Disable();
+
+ control.Enable();
+ }
+
+ private void EnabledChangedEvent(object sender, EventArgs e)
+ {
+ if (!Enabled)
+ return;
+
+ LoadLaddersAndUserAccountsAsync();
+ }
+
+ private void LoadLaddersAndUserAccountsAsync()
+ {
+ qmService.LoadLaddersAndUserAccountsAsync();
+ }
+
+ private void BtnQuickMatch_LeftClick(object sender, EventArgs eventArgs)
+ => RequestQuickMatch();
+
+ private void RequestQuickMatch()
+ {
+ QmRequest matchRequest = GetMatchRequest();
+ if (matchRequest == null)
+ return;
+
+ qmService.RequestMatchAsync(matchRequest);
+ }
+
+ private void HandleQuickMatchResponse(QmRequestResponse qmRequestResponse)
+ {
+ switch (true)
+ {
+ case true when qmRequestResponse.IsError:
+ case true when qmRequestResponse.IsFatal:
+ HandleQuickMatchErrorResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsUpdate:
+ HandleQuickMatchUpdateResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsSpawn:
+ HandleQuickMatchSpawnResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsQuit:
+ HandleQuickMatchQuitResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsQuit:
+ HandleQuickMatchQuitResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsWait:
+ HandleQuickMatchWaitResponse(qmRequestResponse);
+ return;
+ }
+ }
+
+ private void HandleQuickMatchSpawnResponse(QmRequestResponse qmRequestResponse)
+ {
+ XNAMessageBox.Show(WindowManager, QmStrings.GenericErrorTitle, "qm spawn");
+ }
+
+ private void HandleQuickMatchUpdateResponse(QmRequestResponse qmRequestResponse)
+ {
+ XNAMessageBox.Show(WindowManager, QmStrings.GenericErrorTitle, "qm update");
+ }
+
+ private void HandleQuickMatchQuitResponse(QmRequestResponse qmRequestResponse)
+ {
+ XNAMessageBox.Show(WindowManager, QmStrings.GenericErrorTitle, "qm quit");
+ }
+
+ private void HandleQuickMatchWaitResponse(QmRequestResponse qmRequestResponse)
+ {
+ SoundPlayer.Play(matchFoundSoundEffect);
+ Thread.Sleep(qmRequestResponse.CheckBack * 1000);
+ RequestQuickMatch();
+ }
+
+ private void HandleQuickMatchErrorResponse(QmRequestResponse qmRequestResponse)
+ {
+ Console.WriteLine();
+ // qmService.ClearStatus();
+ // XNAMessageBox.Show(WindowManager, QuickMatchWindow.ErrorMessageTitle, qmRequestResponse.Message ?? qmRequestResponse.Description ?? QmStrings.UnknownError);
+ }
+
+ private void HandleQuickMatchRequestResponseEvent(QmRequestResponse response)
+ => HandleQuickMatchResponse(response);
+
+ private QmRequest GetMatchRequest()
+ {
+ QmUserAccount userAccount = GetSelectedUserAccount();
+ if (userAccount == null)
+ {
+ XNAMessageBox.Show(WindowManager, QmStrings.GenericErrorTitle, QmStrings.NoLadderSelectedError);
+ return null;
+ }
+
+ QmSide side = GetSelectedSide();
+ if (side == null)
+ {
+ XNAMessageBox.Show(WindowManager, QmStrings.GenericErrorTitle, QmStrings.NoSideSelectedError);
+ return null;
+ }
+
+ return new QmRequest { Ladder = userAccount.Ladder.Abbreviation, PlayerName = userAccount.Username, Side = side.LocalId };
+ }
+
+ private QmSide GetSelectedSide()
+ => ddSides.SelectedItem?.Tag as QmSide;
+
+ private QmUserAccount GetSelectedUserAccount()
+ => ddUserAccounts.SelectedItem?.Tag as QmUserAccount;
+
+ private void BtnLogout_LeftClick(object sender, EventArgs eventArgs)
+ {
+ XNAMessageBox.ShowYesNoDialog(WindowManager, QmStrings.ConfirmationCaption, QmStrings.LogoutConfirmation, box =>
+ {
+ qmService.Logout();
+ LogoutEvent?.Invoke(this, null);
+ });
+ }
+
+ private void UserAccountsUpdated(IEnumerable userAccounts)
+ {
+ ddUserAccounts.Items.Clear();
+ foreach (QmUserAccount userAccount in userAccounts)
+ {
+ ddUserAccounts.AddItem(new XNADropDownItem() { Text = userAccount.Ladder.Name, Tag = userAccount });
+ }
+
+ if (ddUserAccounts.Items.Count == 0)
+ return;
+
+ string cachedLadder = qmService.GetCachedLadder();
+ if (!string.IsNullOrEmpty(cachedLadder))
+ ddUserAccounts.SelectedIndex = ddUserAccounts.Items.FindIndex(i => (i.Tag as QmUserAccount)?.Ladder.Abbreviation == cachedLadder);
+
+ if (ddUserAccounts.SelectedIndex < 0)
+ ddUserAccounts.SelectedIndex = 0;
+ }
+
+ ///
+ /// Called when the QM service has finished the login process
+ ///
+ ///
+ ///
+ private void HandleLoadLadderAndUserAccountsEvent(QmLaddersAndUserAccountsEvent e)
+ => UserAccountsUpdated(e.UserAccounts);
+
+ ///
+ /// Called when the user has selected a UserAccount from the drop down
+ ///
+ ///
+ ///
+ private void UserAccountSelected(object sender, EventArgs eventArgs)
+ {
+ if (ddUserAccounts.SelectedItem?.Tag is not QmUserAccount selectedUserAccount)
+ return;
+
+ UpdateNickames(selectedUserAccount);
+ UpdateSides(selectedUserAccount);
+ mapList.Clear();
+ qmService.SetLadder(selectedUserAccount.Ladder);
+ }
+
+ private void LoadLadderMapsAsync(QmLadder ladder) => qmService.LoadLadderMapsForAbbrAsync(ladder.Abbreviation);
+
+ private void LoadLadderStatsAsync(QmLadder ladder) => qmService.LoadLadderStatsForAbbrAsync(ladder.Abbreviation);
+
+ ///
+ /// Update the nicknames drop down
+ ///
+ ///
+ private void UpdateNickames(QmUserAccount selectedUserAccount)
+ {
+ ddNicknames.Items.Clear();
+
+ ddNicknames.AddItem(new XNADropDownItem() { Text = selectedUserAccount.Username, Tag = selectedUserAccount });
+
+ ddNicknames.SelectedIndex = 0;
+ }
+
+ ///
+ /// Update the top Sides dropdown
+ ///
+ ///
+ private void UpdateSides(QmUserAccount selectedUserAccount)
+ {
+ ddSides.Items.Clear();
+
+ var ladder = qmService.GetLadderForId(selectedUserAccount.LadderId);
+
+ foreach (QmSide side in ladder.Sides)
+ {
+ ddSides.AddItem(new XNADropDownItem { Text = side.Name, Tag = side });
+ }
+
+ if (ddSides.Items.Count > 0)
+ ddSides.SelectedIndex = 0;
+ }
+
+ ///
+ /// Called when the QM service has loaded new ladder maps for the ladder selected
+ ///
+ ///
+ ///
+ private void HandleLadderMapsEvent(IEnumerable maps)
+ {
+ mapList.Clear();
+ var ladderMaps = maps?.ToList() ?? new List();
+ if (!ladderMaps.Any())
+ return;
+
+ if (ddUserAccounts.SelectedItem?.Tag is not QmUserAccount selectedUserAccount)
+ return;
+
+ var ladder = qmService.GetLadderForId(selectedUserAccount.LadderId);
+
+ mapList.AddItems(ladderMaps.Select(ladderMap => new QuickMatchMapListItem(WindowManager, ladderMap, ladder)));
+ }
+
+ ///
+ /// Called when the QM service has loaded new ladder maps for the ladder selected
+ ///
+ private void HandleLadderStatsEvent(QmLadderStats stats)
+ => lblStats.Text = stats == null ? "No stats found..." : $"Players waiting: {stats.QueuedPlayerCount}, Recent matches: {stats.RecentMatchCount}";
+
+ ///
+ /// Called when the QM service has loaded new ladder maps for the ladder selected
+ ///
+ private void HandleLoadingLadderStatsEvent() => lblStats.Text = QmStrings.LoadingStats;
+
+ private void HandleLadderSelectedEvent(QmLadder ladder)
+ {
+ LoadLadderMapsAsync(ladder);
+ LoadLadderStatsAsync(ladder);
+ }
+
+ private void HandleMatchedEvent()
+ {
+ }
+
+ ///
+ /// Called when the user selects a map in the list
+ ///
+ ///
+ ///
+ private void HandleMapSelectedEventEvent(object sender, QmMap qmMap)
+ {
+ if (qmMap == null)
+ return;
+
+ Map map = mapLoader.GetMapForSHA(qmMap.Hash);
+
+ mapPreviewBox.BackgroundTexture = map?.LoadPreviewTexture();
+ EnableRightPanel(mapPreviewBox);
+ }
+
+ private void HandleLogoutEvent()
+ {
+ Disable();
+ ClearForLogout();
+ }
+
+ public void ClearForLogout()
+ {
+ ddNicknames.Items.Clear();
+ ddNicknames.SelectedIndex = -1;
+ ddSides.Items.Clear();
+ ddSides.SelectedIndex = -1;
+ ddUserAccounts.Items.Clear();
+ ddUserAccounts.SelectedIndex = -1;
+ mapList.Clear();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLoginPanel.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLoginPanel.cs
new file mode 100644
index 000000000..99ca5f594
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLoginPanel.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchLoginPanel : INItializableWindow
+ {
+ public event EventHandler Exit;
+ private const string LoginErrorTitle = "Login Error";
+ private readonly QmService qmService;
+
+ private XNATextBox tbEmail;
+ private XNAPasswordBox tbPassword;
+ private bool loginInitialized;
+
+ public event EventHandler LoginEvent;
+
+ public QuickMatchLoginPanel(WindowManager windowManager) : base(windowManager)
+ {
+ qmService = QmService.GetInstance();
+ qmService.QmEvent += HandleQmEvent;
+ IniNameOverride = nameof(QuickMatchLoginPanel);
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ XNAClientButton btnLogin;
+ btnLogin = FindChild(nameof(btnLogin));
+ btnLogin.LeftClick += BtnLogin_LeftClick;
+
+ XNAClientButton btnCancel;
+ btnCancel = FindChild(nameof(btnCancel));
+ btnCancel.LeftClick += (_, _) => Exit?.Invoke(this, null);
+
+ tbEmail = FindChild(nameof(tbEmail));
+ tbEmail.Text = qmService.GetCachedEmail() ?? string.Empty;
+
+ tbPassword = FindChild(nameof(tbPassword));
+
+ EnabledChanged += InitLogin;
+ }
+
+ private void HandleQmEvent(object sender, QmEvent qmEvent)
+ {
+ switch (qmEvent)
+ {
+ case QmLoginEvent:
+ Disable();
+ return;
+ case QmLogoutEvent:
+ Enable();
+ return;
+ }
+ }
+
+ public void InitLogin(object sender, EventArgs eventArgs)
+ {
+ if (!Enabled || loginInitialized)
+ return;
+
+ if (qmService.IsLoggedIn())
+ qmService.RefreshAsync();
+
+ loginInitialized = true;
+ }
+
+ private void BtnLogin_LeftClick(object sender, EventArgs eventArgs)
+ {
+ if (!ValidateForm())
+ return;
+
+ qmService.LoginAsync(tbEmail.Text, tbPassword.Password);
+ }
+
+ private bool ValidateForm()
+ {
+ if (string.IsNullOrEmpty(tbEmail.Text))
+ {
+ XNAMessageBox.Show(WindowManager, "No Email specified", LoginErrorTitle);
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(tbPassword.Text))
+ {
+ XNAMessageBox.Show(WindowManager, "No Password specified", LoginErrorTitle);
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapList.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapList.cs
new file mode 100644
index 000000000..cb5858caf
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapList.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchMapList : INItializableWindow
+ {
+ private const int MouseScrollRate = 6;
+ public const int ItemHeight = 22;
+ public event EventHandler MapSelectedEvent;
+
+ private XNALabel lblVeto;
+ private XNALabel lblSides;
+ private XNALabel lblMaps;
+ private XNAScrollablePanel mapListPanel;
+ public XNAScrollBar scrollBar { get; private set; }
+
+ public int VetoX => lblVeto?.X ?? 0;
+ public int VetoWidth => lblVeto?.Width ?? 0;
+ public int SidesX => lblSides?.X ?? 0;
+ public int SidesWidth => lblSides?.Width ?? 0;
+ public int MapsX => lblMaps?.X ?? 0;
+ public int MapsWidth => lblMaps?.Width ?? 0;
+
+ public QuickMatchMapList(WindowManager windowManager) : base(windowManager)
+ {
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ lblVeto = FindChild(nameof(lblVeto));
+ lblSides = FindChild(nameof(lblSides));
+ lblMaps = FindChild(nameof(lblMaps));
+ scrollBar = FindChild(nameof(scrollBar));
+ mapListPanel = FindChild(nameof(mapListPanel));
+
+ MouseScrolled += OnMouseScrolled;
+ }
+
+ private void OnMouseScrolled(object sender, EventArgs e)
+ {
+ int viewTop = GetNewScrollBarViewTop();
+ if (viewTop == scrollBar.ViewTop)
+ return;
+
+ scrollBar.ViewTop = viewTop;
+
+ foreach (QuickMatchMapListItem quickMatchMapListItem in MapItemChildren.ToList())
+ {
+ quickMatchMapListItem.CloseDropDowns();
+ }
+ }
+
+ public void AddItems(IEnumerable listItems)
+ {
+ foreach (QuickMatchMapListItem quickMatchMapListItem in listItems)
+ AddItem(quickMatchMapListItem);
+ }
+
+ private void AddItem(QuickMatchMapListItem listItem)
+ {
+ listItem.LeftClickMap += MapItem_LeftClick;
+ listItem.SetParentList(this);
+ mapListPanel.AddChild(listItem);
+ }
+
+ public override void Draw(GameTime gameTime)
+ {
+ var children = MapItemChildren.ToList();
+ scrollBar.Length = children.Count * ItemHeight;
+ scrollBar.DisplayedPixelCount = mapListPanel.Height - 4;
+ scrollBar.Refresh();
+ for (int i = 0; i < children.Count; i++)
+ children[i].ClientRectangle = new Rectangle(0, (i * ItemHeight) - scrollBar.ViewTop, Width - scrollBar.ScrollWidth, ItemHeight);
+
+ base.Draw(gameTime);
+ }
+
+ private int GetNewScrollBarViewTop()
+ {
+ int scrollWheelValue = Cursor.ScrollWheelValue;
+ int viewTop = scrollBar.ViewTop - (scrollWheelValue * MouseScrollRate);
+ int maxViewTop = scrollBar.Length - scrollBar.DisplayedPixelCount;
+
+ if (viewTop < 0)
+ viewTop = 0;
+ else if (viewTop > maxViewTop)
+ viewTop = maxViewTop;
+
+ return viewTop;
+ }
+
+ private void MapItem_LeftClick(object sender, EventArgs eventArgs)
+ {
+ var selectedItem = sender as QuickMatchMapListItem;
+ foreach (QuickMatchMapListItem quickMatchMapItem in MapItemChildren)
+ quickMatchMapItem.Selected = quickMatchMapItem == selectedItem;
+
+ MapSelectedEvent?.Invoke(this, selectedItem?.Map);
+ }
+
+ public void Clear()
+ {
+ foreach (QuickMatchMapListItem child in MapItemChildren.ToList())
+ mapListPanel.RemoveChild(child);
+ }
+
+ private IEnumerable MapItemChildren
+ => mapListPanel.Children.Select(c => c as QuickMatchMapListItem).Where(i => i != null);
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItem.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItem.cs
new file mode 100644
index 000000000..b919adac5
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItem.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Linq;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchMapListItem : XNAPanel
+ {
+ public event EventHandler LeftClickMap;
+
+ private readonly QmLadderMap ladderMap;
+ private readonly QmLadder ladder;
+ private XNAClientCheckBox cbVeto;
+ private QuickMatchMapListItemDropDown ddSide;
+ private XNAPanel panelMap;
+ private XNALabel lblMap;
+ private Color defaultTextColor;
+
+ private XNAPanel topBorder;
+ private XNAPanel bottomBorder;
+ private XNAPanel rightBorder;
+
+ private QuickMatchMapList ParentList;
+
+ public QmMap Map => ladderMap.Map;
+
+ private bool selected;
+
+ public int OpenedDownWindowBottom => GetWindowRectangle().Bottom + (ddSide.ItemHeight * ddSide.Items.Count);
+
+ public bool Selected
+ {
+ get => selected;
+ set
+ {
+ selected = value;
+ panelMap.BackgroundTexture = selected ? AssetLoader.CreateTexture(new Color(255, 0, 0), 1, 1) : null;
+ }
+ }
+
+ public QuickMatchMapListItem(WindowManager windowManager, QmLadderMap ladderMap, QmLadder ladder) : base(windowManager)
+ {
+ this.ladderMap = ladderMap;
+ this.ladder = ladder;
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ DrawBorders = false;
+
+ topBorder = new XNAPanel(WindowManager);
+ topBorder.DrawBorders = true;
+ AddChild(topBorder);
+
+ bottomBorder = new XNAPanel(WindowManager);
+ bottomBorder.DrawBorders = true;
+ AddChild(bottomBorder);
+
+ rightBorder = new XNAPanel(WindowManager);
+ rightBorder.DrawBorders = true;
+ AddChild(rightBorder);
+
+ cbVeto = new XNAClientCheckBox(WindowManager);
+ cbVeto.CheckedChanged += CbVeto_CheckChanged;
+
+ ddSide = new QuickMatchMapListItemDropDown(WindowManager);
+ defaultTextColor = ddSide.TextColor;
+ AddChild(ddSide);
+
+ panelMap = new XNAPanel(WindowManager);
+ panelMap.LeftClick += Map_LeftClicked;
+ panelMap.DrawBorders = false;
+ AddChild(panelMap);
+
+ lblMap = new XNALabel(WindowManager);
+ lblMap.LeftClick += Map_LeftClicked;
+ lblMap.ClientRectangle = new Rectangle(4, 2, panelMap.Width, panelMap.Height);
+ panelMap.AddChild(lblMap);
+ AddChild(cbVeto);
+
+ InitUI();
+ }
+
+ public void SetParentList(QuickMatchMapList parentList) => ParentList = parentList;
+
+ public override void Draw(GameTime gameTime)
+ {
+ ddSide.OpenUp = OpenedDownWindowBottom > ParentList.scrollBar.GetWindowRectangle().Bottom;
+
+
+ base.Draw(gameTime);
+ }
+
+ private void CbVeto_CheckChanged(object sender, EventArgs e)
+ {
+ ddSide.TextColor = cbVeto.Checked ? UISettings.ActiveSettings.DisabledItemColor : defaultTextColor;
+ lblMap.TextColor = cbVeto.Checked ? UISettings.ActiveSettings.DisabledItemColor : defaultTextColor;
+ ddSide.AllowDropDown = !cbVeto.Checked;
+ }
+
+ private void Map_LeftClicked(object sender, EventArgs eventArgs) => LeftClickMap?.Invoke(this, EventArgs.Empty);
+
+ private void InitUI()
+ {
+ ddSide.Items.Clear();
+ foreach (int ladderMapAllowedSideId in ladderMap.AllowedSideIds)
+ {
+ var side = ladder.Sides.FirstOrDefault(s => s.LocalId == ladderMapAllowedSideId);
+ if (side == null)
+ continue;
+
+ ddSide.AddItem(new XNADropDownItem()
+ {
+ Text = side.Name,
+ Tag = side
+ });
+ }
+
+ if (ddSide.Items.Count > 0)
+ ddSide.SelectedIndex = 0;
+
+ lblMap.Text = ladderMap.Description;
+
+ cbVeto.ClientRectangle = new Rectangle(ParentList.VetoX, 0, ParentList.VetoWidth, QuickMatchMapList.ItemHeight);
+ ddSide.ClientRectangle = new Rectangle(ParentList.SidesX, 0, ParentList.SidesWidth, QuickMatchMapList.ItemHeight);
+ panelMap.ClientRectangle = new Rectangle(ParentList.MapsX, 0, ParentList.MapsWidth, QuickMatchMapList.ItemHeight);
+
+ topBorder.ClientRectangle = new Rectangle(panelMap.X, panelMap.Y, panelMap.Width, 1);
+ bottomBorder.ClientRectangle = new Rectangle(panelMap.X, panelMap.Bottom, panelMap.Width, 1);
+ rightBorder.ClientRectangle = new Rectangle(panelMap.Right, panelMap.Y, 1, panelMap.Height);
+ }
+
+ public bool IsVetoed() => cbVeto.Checked;
+
+ public bool ContainsPointVertical(Point point) => Y < point.Y && Y + Height < point.Y;
+
+ public void CloseDropDowns()
+ {
+ ddSide.Close();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItemDropDown.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItemDropDown.cs
new file mode 100644
index 000000000..83579988c
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItemDropDown.cs
@@ -0,0 +1,21 @@
+using ClientGUI;
+using Rampastring.XNAUI;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch;
+
+public class QuickMatchMapListItemDropDown : XNAClientDropDown
+{
+ public QuickMatchMapListItemDropDown(WindowManager windowManager) : base(windowManager)
+ {
+ }
+
+ public override void OnMouseScrolled()
+ {
+ // override this to prevent mouse scrolling on the drop down
+ }
+
+ public void Close()
+ {
+ CloseDropDown();
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchStatusOverlay.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchStatusOverlay.cs
new file mode 100644
index 000000000..fefb8da05
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchStatusOverlay.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Linq;
+using System.Timers;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchStatusOverlay : INItializableWindow
+ {
+ private int DefaultInternalWidth;
+
+ private XNAPanel statusOverlayBox { get; set; }
+
+ private XNALabel statusMessage { get; set; }
+
+ private XNAClientButton cancelBtn { get; set; }
+
+ private Action cancelAction { get; set; }
+
+ private Type lastQmLoadingEventType { get; set; }
+
+ private string currentMessage { get; set; }
+
+ private readonly Timer loadingMessageTimer;
+
+ private const int loadingMessageAppendLength = 4;
+
+ public QuickMatchStatusOverlay(WindowManager windowManager) : base(windowManager)
+ {
+ QmService.GetInstance().QmEvent += HandleQmEvent;
+
+ loadingMessageTimer = new Timer(500);
+ loadingMessageTimer.Enabled = true;
+ loadingMessageTimer.AutoReset = true;
+ loadingMessageTimer.Elapsed += AppendLoadingMessage;
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ statusOverlayBox = FindChild(nameof(statusOverlayBox));
+ DefaultInternalWidth = statusOverlayBox.ClientRectangle.Width;
+
+ statusMessage = FindChild(nameof(statusMessage));
+
+ cancelBtn = FindChild(nameof(cancelBtn));
+ cancelBtn.LeftClick += CancelButton_LeftClick;
+ }
+
+ private void HandleQmEvent(object sender, QmEvent qmEvent)
+ {
+ switch (qmEvent)
+ {
+ case QmLoggingInEvent:
+ HandleLoggingInEvent();
+ break;
+ case QmLoginEvent:
+ HandleLoginEvent();
+ break;
+ case QmLoadingLaddersAndUserAccountsEvent:
+ HandleLoadingLaddersAndUserAccountsEvent();
+ break;
+ case QmLaddersAndUserAccountsEvent:
+ HandleLaddersAndUserAccountsEvent();
+ break;
+ case QmLoadingLadderMapsEvent:
+ HandleLoadingLadderMapsEvent();
+ break;
+ case QmLadderMapsEvent:
+ HandleLadderMapsEvent();
+ break;
+ case QmRequestingMatchEvent e:
+ HandleRequestingMatchEvent(e);
+ break;
+ case QmCancelingRequestMatchEvent:
+ HandleCancelingMatchRequest();
+ break;
+ case QmErrorMessageEvent:
+ Disable();
+ return;
+ case QmRequestResponseEvent e:
+ HandleRequestResponseEvent(e);
+ return;
+ }
+
+ if (qmEvent is IQmOverlayLoadingStatusEvent)
+ lastQmLoadingEventType = qmEvent.GetType();
+ }
+
+ private void HandleLoggingInEvent() => SetStatus(QmStrings.LoggingInStatus);
+
+ private void HandleLoginEvent() => CloseIfLastEventType(typeof(QmLoggingInEvent));
+
+ private void HandleLoadingLaddersAndUserAccountsEvent() => SetStatus(QmStrings.LoadingLaddersAndAccountsStatus);
+
+ private void HandleLaddersAndUserAccountsEvent() => CloseIfLastEventType(typeof(QmLoadingLaddersAndUserAccountsEvent));
+
+ private void HandleLoadingLadderMapsEvent() => SetStatus(QmStrings.LoadingLadderMapsStatus);
+
+ private void HandleLadderMapsEvent() => CloseIfLastEventType(typeof(QmLoadingLadderMapsEvent));
+
+ private void HandleRequestingMatchEvent(QmRequestingMatchEvent e) => SetStatus(QmStrings.RequestingMatchStatus, e.CancelAction);
+
+ private void HandleRequestResponseEvent(QmRequestResponseEvent e)
+ {
+ QmRequestResponse response = e.Response;
+ switch (true)
+ {
+ case true when response.IsWait:
+ return; // need to keep the overlay open while waiting
+ default:
+ CloseIfLastEventType(typeof(QmRequestingMatchEvent), typeof(QmCancelingRequestMatchEvent));
+ return;
+ }
+ }
+
+ private void HandleCancelingMatchRequest() => SetStatus(QmStrings.CancelingMatchRequestStatus);
+
+ private void CloseIfLastEventType(params Type[] lastEventType)
+ {
+ if (lastEventType.Any(t => t == lastQmLoadingEventType))
+ Disable();
+ }
+
+ private void SetStatus(string message, Action messageCancelAction = null)
+ {
+ currentMessage = message;
+ statusMessage.Text = message;
+ cancelAction = messageCancelAction;
+ if (cancelAction != null)
+ cancelBtn.Enable();
+ else
+ cancelBtn.Disable();
+
+ ResizeForText();
+ Enable();
+ }
+
+ private void AppendLoadingMessage(object sender, ElapsedEventArgs elapsedEventArgs)
+ {
+ if (string.IsNullOrEmpty(currentMessage))
+ return;
+
+ int maxLength = currentMessage.Length + loadingMessageAppendLength;
+ statusMessage.Text = statusMessage.Text.Length >= maxLength ? statusMessage.Text.Substring(0, currentMessage.Length) : statusMessage.Text + ".";
+ }
+
+ private void ResizeForText()
+ {
+ Vector2 textDimensions = Renderer.GetTextDimensions(statusMessage.Text, statusMessage.FontIndex);
+
+ statusOverlayBox.Width = (int)Math.Max(DefaultInternalWidth, textDimensions.X + loadingMessageAppendLength + 60);
+ statusOverlayBox.X = (Width / 2) - (statusOverlayBox.Width / 2);
+ }
+
+ private void CancelButton_LeftClick(object sender, EventArgs eventArgs)
+ {
+ Disable();
+ statusMessage.Text = string.Empty;
+ cancelAction?.Invoke();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchWindow.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchWindow.cs
new file mode 100644
index 000000000..8731c7b3b
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchWindow.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Timers;
+using ClientCore.Exceptions;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+using Timer = System.Timers.Timer;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchWindow : INItializableWindow
+ {
+ private readonly QmService qmService;
+ private readonly QmSettingsService qmSettingsService;
+
+ private QuickMatchLoginPanel loginPanel;
+
+ private QuickMatchLobbyPanel lobbyPanel;
+
+ private XNAPanel headerGameLogo;
+
+ public QuickMatchWindow(WindowManager windowManager) : base(windowManager)
+ {
+ qmService = QmService.GetInstance();
+ qmService.QmEvent += HandleQmEvent;
+ qmSettingsService = QmSettingsService.GetInstance();
+ }
+
+ public override void Initialize()
+ {
+ Name = nameof(QuickMatchWindow);
+ BackgroundTexture = AssetLoader.CreateTexture(new Color(0, 0, 0, 255), 1, 1);
+ PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED;
+
+ base.Initialize();
+
+ loginPanel = FindChild(nameof(loginPanel));
+ loginPanel.Exit += (sender, args) => Disable();
+
+ lobbyPanel = FindChild(nameof(lobbyPanel));
+ lobbyPanel.Exit += (sender, args) => Disable();
+
+ headerGameLogo = FindChild(nameof(headerGameLogo));
+
+ WindowManager.CenterControlOnScreen(this);
+
+ EnabledChanged += EnabledChangedEvent;
+ }
+
+ private void HandleQmEvent(object sender, QmEvent qmEvent)
+ {
+ switch (qmEvent)
+ {
+ case QmLadderSelectedEvent e:
+ HandleLadderSelectedEvent(e.Ladder);
+ return;
+ case QmErrorMessageEvent e:
+ HandleErrorMessageEvent(e);
+ return;
+ }
+ }
+
+ private void HandleErrorMessageEvent(QmErrorMessageEvent e)
+ => XNAMessageBox.Show(WindowManager, e.ErrorTitle, e.ErrorMessage);
+
+ private void HandleLadderSelectedEvent(QmLadder ladder)
+ {
+ headerGameLogo.BackgroundTexture = qmSettingsService.GetSettings().GetLadderHeaderLogo(ladder.Abbreviation);
+ if (headerGameLogo.BackgroundTexture == null)
+ return;
+
+ // Resize image to ensure proper ratio and spacing from right edge
+ float imageRatio = (float)headerGameLogo.BackgroundTexture.Width / headerGameLogo.BackgroundTexture.Height;
+ int newImageWidth = (int)imageRatio * headerGameLogo.Height;
+ headerGameLogo.ClientRectangle = new Rectangle(headerGameLogo.Parent.Right - newImageWidth - headerGameLogo.Parent.X, headerGameLogo.Y, newImageWidth, headerGameLogo.Height);
+ }
+
+ private void EnabledChangedEvent(object sender, EventArgs e)
+ {
+ if (!Enabled)
+ return;
+
+ loginPanel.Enable();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXMainClient.csproj b/DXMainClient/DXMainClient.csproj
index b8108e0a4..6e5dcc672 100644
--- a/DXMainClient/DXMainClient.csproj
+++ b/DXMainClient/DXMainClient.csproj
@@ -43,9 +43,11 @@
+
+
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmStatusMessageEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmStatusMessageEventArgs.cs
new file mode 100644
index 000000000..3e441eb20
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmStatusMessageEventArgs.cs
@@ -0,0 +1,17 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmStatusMessageEventArgs : EventArgs
+ {
+ public string Message { get; }
+
+ public Action CancelAction { get; }
+
+ public QmStatusMessageEventArgs(string message, Action cancelAction = null)
+ {
+ Message = message;
+ CancelAction = cancelAction;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/IQmOverlayLoadingStatusEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/IQmOverlayLoadingStatusEvent.cs
new file mode 100644
index 000000000..eba55092b
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/IQmOverlayLoadingStatusEvent.cs
@@ -0,0 +1,6 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public interface IQmOverlayLoadingStatusEvent
+{
+
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmCancelingRequestMatchEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmCancelingRequestMatchEvent.cs
new file mode 100644
index 000000000..90fb8cb40
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmCancelingRequestMatchEvent.cs
@@ -0,0 +1,6 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmCancelingRequestMatchEvent : QmEvent
+{
+
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmErrorMessageEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmErrorMessageEvent.cs
new file mode 100644
index 000000000..58e47bb1a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmErrorMessageEvent.cs
@@ -0,0 +1,14 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmErrorMessageEvent : QmEvent
+{
+ public string ErrorTitle { get; }
+
+ public string ErrorMessage { get; }
+
+ public QmErrorMessageEvent(string errorMessage, string errorTitle = null)
+ {
+ ErrorMessage = errorMessage;
+ ErrorTitle = errorTitle ?? QmStrings.GenericErrorTitle;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmEvent.cs
new file mode 100644
index 000000000..2e44336a1
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmEvent.cs
@@ -0,0 +1,6 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public abstract class QmEvent
+{
+
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderMapsEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderMapsEvent.cs
new file mode 100644
index 000000000..679ed4c1f
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderMapsEvent.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLadderMapsEvent : QmEvent
+{
+ public IEnumerable LadderMaps { get; }
+
+ public QmLadderMapsEvent(IEnumerable qmLadderMaps)
+ {
+ LadderMaps = qmLadderMaps;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderSelectedEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderSelectedEvent.cs
new file mode 100644
index 000000000..3243e939a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderSelectedEvent.cs
@@ -0,0 +1,11 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLadderSelectedEvent : QmEvent
+{
+ public QmLadder Ladder { get; }
+
+ public QmLadderSelectedEvent(QmLadder qmLadder)
+ {
+ Ladder = qmLadder;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderStatsEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderStatsEvent.cs
new file mode 100644
index 000000000..e645ae13d
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLadderStatsEvent.cs
@@ -0,0 +1,11 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLadderStatsEvent : QmEvent
+{
+ public QmLadderStats LadderStats { get; }
+
+ public QmLadderStatsEvent(QmLadderStats ladderStats)
+ {
+ LadderStats = ladderStats;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLaddersAndUserAccountsEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLaddersAndUserAccountsEvent.cs
new file mode 100644
index 000000000..22faddafd
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLaddersAndUserAccountsEvent.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLaddersAndUserAccountsEvent : QmEvent
+{
+ public IEnumerable Ladders { get; }
+
+ public IEnumerable UserAccounts { get; }
+
+ public QmLaddersAndUserAccountsEvent(IEnumerable ladders, IEnumerable userAccounts)
+ {
+ Ladders = ladders;
+ UserAccounts = userAccounts;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLadderMapsEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLadderMapsEvent.cs
new file mode 100644
index 000000000..0edbb3904
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLadderMapsEvent.cs
@@ -0,0 +1,5 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLoadingLadderMapsEvent : QmEvent, IQmOverlayLoadingStatusEvent
+{
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLadderStatsEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLadderStatsEvent.cs
new file mode 100644
index 000000000..508dc9d6b
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLadderStatsEvent.cs
@@ -0,0 +1,5 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLoadingLadderStatsEvent : QmEvent
+{
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLaddersAndUserAccountsEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLaddersAndUserAccountsEvent.cs
new file mode 100644
index 000000000..c5ed27ea6
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoadingLaddersAndUserAccountsEvent.cs
@@ -0,0 +1,5 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLoadingLaddersAndUserAccountsEvent : QmEvent, IQmOverlayLoadingStatusEvent
+{
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoggingInEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoggingInEvent.cs
new file mode 100644
index 000000000..5a2eff542
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoggingInEvent.cs
@@ -0,0 +1,5 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLoggingInEvent : QmEvent, IQmOverlayLoadingStatusEvent
+{
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoginEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoginEvent.cs
new file mode 100644
index 000000000..42ad67a7c
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLoginEvent.cs
@@ -0,0 +1,6 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLoginEvent : QmEvent
+{
+ public string ErrorMessage { get; set; }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLogoutEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLogoutEvent.cs
new file mode 100644
index 000000000..a4ffa138f
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmLogoutEvent.cs
@@ -0,0 +1,5 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmLogoutEvent : QmEvent
+{
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmRequestResponseEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmRequestResponseEvent.cs
new file mode 100644
index 000000000..38dcb70ae
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmRequestResponseEvent.cs
@@ -0,0 +1,11 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmRequestResponseEvent : QmEvent
+{
+ public QmRequestResponse Response { get; }
+
+ public QmRequestResponseEvent(QmRequestResponse response)
+ {
+ Response = response;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmRequestingMatchEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmRequestingMatchEvent.cs
new file mode 100644
index 000000000..7b6955fdf
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/Events/QmRequestingMatchEvent.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+
+public class QmRequestingMatchEvent : QmEvent, IQmOverlayLoadingStatusEvent
+{
+ public Action CancelAction { get; }
+
+ public QmRequestingMatchEvent(Action cancelAction)
+ {
+ CancelAction = cancelAction;
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmAuthData.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmAuthData.cs
new file mode 100644
index 000000000..d5313ce49
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmAuthData.cs
@@ -0,0 +1,9 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmAuthData
+ {
+ public string Token { get; set; }
+ public string Email { get; set; }
+ public string Name { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmData.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmData.cs
new file mode 100644
index 000000000..21afe7308
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmData.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmData
+ {
+ public List Ladders { get; set; }
+
+ public List UserAccounts { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadder.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadder.cs
new file mode 100644
index 000000000..b13d61719
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadder.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmLadder
+ {
+ [JsonProperty("id")]
+ public long Id { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ [JsonProperty("abbreviation")]
+ public string Abbreviation { get; set; }
+
+ [JsonProperty("game")]
+ public string Game { get; set; }
+
+ [JsonProperty("clans_allowed")]
+ private int clansAllowed { get; set; }
+
+ [JsonIgnore]
+ public bool ClansAllowed => clansAllowed == 1;
+
+ [JsonProperty("game_object_schema_id")]
+ public int GameObjectSchemaId { get; set; }
+
+ [JsonProperty("map_pool_id")]
+ public int MapPoolId { get; set; }
+
+ [JsonProperty("private")]
+ private int _private { get; set; }
+
+ [JsonIgnore]
+ public bool IsPrivate => _private == 1;
+
+ [JsonProperty("sides")]
+ public IEnumerable Sides { get; set; }
+
+ [JsonProperty("vetoes")]
+ public int VetoesRemaining { get; set; }
+
+ [JsonProperty("allowed_sides")]
+ public IEnumerable AllowedSideLocalIds { get; set; }
+
+ [JsonProperty("current")]
+ public string Current { get; set; }
+
+ [JsonProperty("qm_ladder_rules")]
+ public QmLadderRules LadderRules { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderMap.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderMap.cs
new file mode 100644
index 000000000..b07cdc83c
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderMap.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmLadderMap
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("map_id")]
+ public int MapId { get; set; }
+
+ [JsonProperty("description")]
+ public string Description { get; set; }
+
+ [JsonProperty("bit_idx")]
+ public int BitIndex { get; set; }
+
+ [JsonProperty("valid")]
+ private int valid { get; set; }
+
+ [JsonIgnore]
+ public bool IsValid => valid == 1;
+
+ [JsonProperty("spawn_order")]
+ public string SpawnOrder { get; set; }
+
+ [JsonProperty("team1_spawn_order")]
+ public string Team1SpawnOrder { get; set; }
+
+ [JsonProperty("team2_spawn_order")]
+ public string Team2SpawnOrder { get; set; }
+
+ [JsonProperty("allowed_sides")]
+ public IEnumerable AllowedSideIds { get; set; }
+
+ [JsonProperty("admin_description")]
+ public string AdminDescription { get; set; }
+
+ [JsonProperty("map_pool_id")]
+ public int MapPoolId { get; set; }
+
+ [JsonProperty("rejectable")]
+ private int rejectable { get; set; }
+
+ [JsonIgnore]
+ public bool IsRejectable => rejectable == 1;
+
+ [JsonProperty("default_reject")]
+ private int defaultReject { get; set; }
+
+ [JsonIgnore]
+ public bool IsDefaultReject => defaultReject == 1;
+
+ [JsonProperty("hash")]
+ public string Hash { get; set; }
+
+ [JsonProperty("map")]
+ public QmMap Map { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderRules.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderRules.cs
new file mode 100644
index 000000000..d62704c67
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderRules.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmLadderRules
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("player_count")]
+ public int PlayerCount { get; set; }
+
+ [JsonProperty("map_vetoes")]
+ public int MapVetoes { get; set; }
+
+ [JsonProperty("max_difference")]
+ public int MaxDifference { get; set; }
+
+ [JsonProperty("all_sides")]
+ private string allSides { get; set; }
+
+ [JsonIgnore]
+ public IEnumerable AllSides => allSides?.Split(',').Select(int.Parse) ?? new List();
+
+ [JsonProperty("allowed_sides")]
+ private string allowedSides { get; set; }
+
+ [JsonIgnore]
+ public IEnumerable AllowedSides => allSides?.Split(',').Select(int.Parse) ?? new List();
+
+ [JsonProperty("bail_time")]
+ public int BailTime { get; set; }
+
+ [JsonProperty("bail_fps")]
+ public int BailFps { get; set; }
+
+ [JsonProperty("tier2_rating")]
+ public int Tier2Rating { get; set; }
+
+ [JsonProperty("rating_per_second")]
+ public double RatingPerSecond { get; set; }
+
+ [JsonProperty("max_points_difference")]
+ public int MaxPointsDifference { get; set; }
+
+ [JsonProperty("points_per_second")]
+ public int PointsPerSecond { get; set; }
+
+ [JsonProperty("use_elo_points")]
+ private int useEloPoints { get; set; }
+
+ [JsonIgnore]
+ public bool UseEloPoints => useEloPoints == 1;
+
+ [JsonProperty("wol_k")]
+ public int WolK { get; set; }
+
+ [JsonProperty("show_map_preview")]
+ private int showMapPreview { get; set; }
+
+ [JsonIgnore]
+ public bool ShowMapPreview => showMapPreview == 1;
+
+ [JsonProperty("reduce_map_repeats")]
+ private int reduceMapRepeats { get; set; }
+
+ [JsonIgnore]
+ public bool ReduceMapRepeats => reduceMapRepeats == 1;
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderStats.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderStats.cs
new file mode 100644
index 000000000..82f351e02
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLadderStats.cs
@@ -0,0 +1,26 @@
+using System;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmLadderStats
+ {
+ [JsonProperty("recentMatchedPlayers")]
+ public int RecentMatchedPlayerCount { get; set; }
+
+ [JsonProperty("queuedPlayers")]
+ public int QueuedPlayerCount { get; set; }
+
+ [JsonProperty("past24hMatches")]
+ public int Past24HourMatchCount { get; set; }
+
+ [JsonProperty("recentMatches")]
+ public int RecentMatchCount { get; set; }
+
+ [JsonProperty("activeMatches")]
+ public int ActiveMatchCount { get; set; }
+
+ [JsonProperty("time")]
+ public DateTime DateTime { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLoginRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLoginRequest.cs
new file mode 100644
index 000000000..987ecbde7
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmLoginRequest.cs
@@ -0,0 +1,13 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QMLoginRequest
+ {
+ [JsonProperty("email")]
+ public string Email { get; set; }
+
+ [JsonProperty("password")]
+ public string Password { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmMap.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmMap.cs
new file mode 100644
index 000000000..406e2e967
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmMap.cs
@@ -0,0 +1,19 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmMap
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("hash")]
+ public string Hash { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmRequest.cs
new file mode 100644
index 000000000..93ff59727
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmRequest.cs
@@ -0,0 +1,67 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmRequest
+ {
+ [JsonProperty("type")]
+ public string Type { get; set; } = QmRequestTypes.MatchMeUp;
+
+ [JsonProperty("lan_ip")]
+ public string LanIP { get; set; }
+
+ [JsonProperty("lan_port")]
+ public string LanPort { get; set; }
+
+ [JsonProperty("ipv6_address")]
+ public string IPv6Address { get; set; }
+
+ [JsonProperty("ipv6_port")]
+ public string IPv6Port { get; set; }
+
+ [JsonProperty("ip_address")]
+ public string IPAddress { get; set; }
+
+ [JsonProperty("ip_port")]
+ public string IPPort { get; set; }
+
+ [JsonProperty("side")]
+ public int Side { get; set; }
+
+ [JsonProperty("map_bitfield")]
+ public string MapBitfield { get; set; }
+
+ [JsonProperty("version")]
+ public string Version { get; set; } = "2.0";
+
+ [JsonProperty("platform")]
+ public string Platform { get; set; }
+
+ [JsonProperty("map_sides")]
+ public string[] MapSides { get; set; }
+
+ [JsonProperty("ai_dat")]
+ public string CheatSeen { get; set; }
+
+ [JsonProperty("exe_hash")]
+ public string ExeHash { get; set; }
+
+ [JsonProperty("ddraw")]
+ public string DDrawHash { get; set; }
+
+ [JsonProperty("session")]
+ public string Session { get; set; }
+
+ [JsonIgnore]
+ public string Ladder { get; set; }
+
+ [JsonIgnore]
+ public string PlayerName { get; set; }
+
+ private bool IsType(string type) => Type == type;
+
+ public bool IsMatchMeUp() => IsType(QmRequestTypes.MatchMeUp);
+
+ public bool IsQuit() => IsType(QmRequestTypes.Quit);
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmRequestResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmRequestResponse.cs
new file mode 100644
index 000000000..1a0a76f81
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmRequestResponse.cs
@@ -0,0 +1,38 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmRequestResponse
+ {
+ [JsonProperty("type")]
+ public string Type { get; set; }
+
+ [JsonProperty("description")]
+ public string Description { get; set; }
+
+ [JsonProperty("message")]
+ public string Message { get; set; }
+
+ [JsonProperty("checkback")]
+ public int CheckBack { get; set; }
+
+ [JsonProperty("no_sooner_than")]
+ public int NoSoonerThan { get; set; }
+
+ public bool IsError => IsType(QmResponseTypes.Error);
+
+ public bool IsFatal => IsType(QmResponseTypes.Fatal);
+
+ public bool IsSpawn => IsType(QmResponseTypes.Spawn);
+
+ public bool IsUpdate => IsType(QmResponseTypes.Update);
+
+ public bool IsQuit => IsType(QmResponseTypes.Quit);
+
+ public bool IsWait => IsType(QmResponseTypes.Wait);
+
+ public bool IsSuccessful => !IsError && !IsFatal;
+
+ private bool IsType(string type) => Type == type;
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmSettings.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmSettings.cs
new file mode 100644
index 000000000..bb68274cb
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmSettings.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using Microsoft.Xna.Framework.Graphics;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmSettings
+ {
+ public const string DefaultBaseUrl = "https://ladder.cncnet.org";
+ public const string DefaultLoginUrl = "/api/v1/auth/login";
+ public const string DefaultRefreshUrl = "/api/v1/auth/refresh";
+ public const string DefaultServerStatusUrl = "/api/v1/ping";
+ public const string DefaultGetUserAccountsUrl = "/api/v1/user/account";
+ public const string DefaultGetLaddersUrl = "/api/v1/ladder";
+ public const string DefaultGetLadderMapsUrl = "/api/v1/qm/ladder/{0}/maps";
+ public const string DefaultGetLadderStatsUrl = "/api/v1/qm/ladder/{0}/stats";
+ public const string DefaultQuickMatchUrl = "/api/v1/qm/{0}/{1}";
+
+ public string BaseUrl { get; set; } = DefaultBaseUrl;
+
+ public string LoginUrl { get; set; } = DefaultLoginUrl;
+
+ public string RefreshUrl { get; set; } = DefaultRefreshUrl;
+
+ public string ServerStatusUrl { get; set; } = DefaultServerStatusUrl;
+
+ public string GetUserAccountsUrl { get; set; } = DefaultGetUserAccountsUrl;
+
+ public string GetLaddersUrl { get; set; } = DefaultGetLaddersUrl;
+
+ public string GetLadderMapsUrlFormat { get; set; } = DefaultGetLadderMapsUrl;
+
+ public string GetLadderStatsUrlFormat { get; set; } = DefaultGetLadderStatsUrl;
+
+ public string QuickMatchUrlFormat { get; set; } = DefaultQuickMatchUrl;
+
+ public string MatchFoundSoundFile { get; set; }
+
+ public IDictionary HeaderLogos = new Dictionary();
+
+ public Texture2D GetLadderHeaderLogo(string ladder)
+ => !HeaderLogos.ContainsKey(ladder) ? null : HeaderLogos[ladder];
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmSide.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmSide.cs
new file mode 100644
index 000000000..6810700ff
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmSide.cs
@@ -0,0 +1,19 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmSide
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("local_id")]
+ public int LocalId { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmUserAccount.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmUserAccount.cs
new file mode 100644
index 000000000..2c189cb6e
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmUserAccount.cs
@@ -0,0 +1,22 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmUserAccount
+ {
+ [JsonProperty("id")]
+ public long Id { get; set; }
+
+ [JsonProperty("username")]
+ public string Username { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("card_id")]
+ public int CardId { get; set; }
+
+ [JsonProperty("ladder")]
+ public QmLadder Ladder { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmUserSettings.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmUserSettings.cs
new file mode 100644
index 000000000..8bd4374f2
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/Models/QmUserSettings.cs
@@ -0,0 +1,11 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models
+{
+ public class QmUserSettings
+ {
+ public string Email { get; set; }
+
+ public string Ladder { get; set; }
+
+ public QmAuthData AuthData { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmApiService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmApiService.cs
new file mode 100644
index 000000000..b1791519c
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmApiService.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using Newtonsoft.Json;
+using Rampastring.Tools;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmApiService : IDisposable
+ {
+ private HttpClient _httpClient;
+ private readonly QmSettings qmSettings;
+ private string _token;
+
+ public QmApiService()
+ {
+ qmSettings = QmSettingsService.GetInstance().GetSettings();
+ }
+
+ public void SetToken(string token)
+ {
+ _token = token;
+ HttpClient httpClient = GetHttpClient();
+ httpClient.DefaultRequestHeaders.Clear();
+ if (!string.IsNullOrEmpty(token))
+ httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_token}");
+ }
+
+ public async Task> LoadLadderMapsForAbbrAsync(string ladderAbbreviation)
+ {
+ HttpClient httpClient = GetHttpClient();
+ string url = string.Format(qmSettings.GetLadderMapsUrlFormat, ladderAbbreviation);
+ HttpResponseMessage response = await httpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException(string.Format(QmStrings.LoadingLadderMapsErrorFormat, response.ReasonPhrase));
+
+ return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync());
+ }
+
+ public async Task LoadLadderStatsForAbbrAsync(string ladderAbbreviation)
+ {
+ HttpClient httpClient = GetHttpClient();
+ string url = string.Format(qmSettings.GetLadderStatsUrlFormat, ladderAbbreviation);
+ HttpResponseMessage response = await httpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException(string.Format(QmStrings.LoadingLadderStatsErrorFormat, response.ReasonPhrase));
+
+ return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync());
+ }
+
+ public async Task> LoadUserAccountsAsync()
+ {
+ HttpClient httpClient = GetHttpClient();
+ HttpResponseMessage response = await httpClient.GetAsync(qmSettings.GetUserAccountsUrl);
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException(string.Format(QmStrings.LoadingUserAccountsErrorFormat, response.ReasonPhrase));
+
+ return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync());
+ }
+
+ public async Task> LoadLaddersAsync()
+ {
+ HttpClient httpClient = GetHttpClient();
+ HttpResponseMessage response = await httpClient.GetAsync(qmSettings.GetLaddersUrl);
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException(string.Format(QmStrings.LoadingLaddersErrorFormat, response.ReasonPhrase));
+
+ return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync());
+ }
+
+ public async Task LoginAsync(string email, string password)
+ {
+ HttpClient httpClient = GetHttpClient();
+ var postBodyContent = new StringContent(JsonConvert.SerializeObject(new QMLoginRequest() { Email = email, Password = password }), Encoding.Default, "application/json");
+ var response = await httpClient.PostAsync(qmSettings.LoginUrl, postBodyContent);
+
+ return await HandleLoginResponse(response, QmStrings.LoggingInUnknownErrorFormat);
+ }
+
+ public async Task RefreshAsync()
+ {
+ HttpClient httpClient = GetHttpClient();
+ HttpResponseMessage response = await httpClient.GetAsync(qmSettings.RefreshUrl);
+
+ return await HandleLoginResponse(response, "Error refreshing token: {0}, {1}");
+ }
+
+ private async Task HandleLoginResponse(HttpResponseMessage response, string unknownErrorFormat)
+ {
+ if (!response.IsSuccessStatusCode)
+ return await HandleFailedLoginResponse(response, unknownErrorFormat);
+
+ string responseBody = await response.Content.ReadAsStringAsync();
+ QmAuthData authData = JsonConvert.DeserializeObject(responseBody);
+ if (authData == null)
+ throw new ClientException(responseBody);
+
+ return authData;
+ }
+
+ private async Task HandleFailedLoginResponse(HttpResponseMessage response, string unknownErrorFormat)
+ {
+ string responseBody = await response.Content.ReadAsStringAsync();
+ string message;
+ switch (response.StatusCode)
+ {
+ case HttpStatusCode.BadGateway:
+ message = QmStrings.ServerUnreachableError;
+ break;
+ case HttpStatusCode.Unauthorized:
+ message = QmStrings.InvalidUsernamePasswordError;
+ break;
+ default:
+ message = string.Format(unknownErrorFormat, response.ReasonPhrase, responseBody);
+ break;
+ }
+
+ throw new ClientRequestException(message, response.StatusCode);
+ }
+
+ public bool IsServerAvailable()
+ {
+ HttpClient httpClient = GetHttpClient();
+ HttpResponseMessage response = httpClient.GetAsync(qmSettings.ServerStatusUrl).Result;
+ return response.IsSuccessStatusCode;
+ }
+
+ private HttpClient GetHttpClient() =>
+ _httpClient ??= new HttpClient { BaseAddress = new Uri(qmSettings.BaseUrl), Timeout = TimeSpan.FromSeconds(10) };
+
+
+ public async Task QuickMatchRequestAsync(QmRequest qmRequest)
+ {
+ HttpClient httpClient = GetHttpClient();
+ string url = string.Format(qmSettings.QuickMatchUrlFormat, qmRequest.Ladder, qmRequest.PlayerName);
+ HttpResponseMessage response = await httpClient.PostAsync(url, new StringContent(JsonConvert.SerializeObject(qmRequest), Encoding.Default, "application/json"));
+
+ string responseBody = await response.Content.ReadAsStringAsync();
+ Logger.Log(responseBody);
+ QmRequestResponse matchRequestResponse = JsonConvert.DeserializeObject(responseBody);
+
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException(string.Format(QmStrings.RequestingMatchErrorFormat, response.ReasonPhrase));
+
+ if (!(matchRequestResponse?.IsSuccessful ?? false))
+ throw new ClientException(string.Format(QmStrings.RequestingMatchErrorFormat, matchRequestResponse?.Message ?? matchRequestResponse?.Description ?? "unknown"));
+
+ return matchRequestResponse;
+ }
+
+ public void Dispose()
+ {
+ _httpClient?.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestTypes.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestTypes.cs
new file mode 100644
index 000000000..dccee908a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestTypes.cs
@@ -0,0 +1,9 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public static class QmRequestTypes
+ {
+ public const string Quit = "quit";
+ public const string Update = "update";
+ public const string MatchMeUp = "match me up";
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmResponseTypes.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmResponseTypes.cs
new file mode 100644
index 000000000..6fcdfe7dd
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmResponseTypes.cs
@@ -0,0 +1,12 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public static class QmResponseTypes
+ {
+ public const string Error = "error";
+ public const string Fatal = "fatal";
+ public const string Spawn = "spawn";
+ public const string Update = "update";
+ public const string Quit = "quit";
+ public const string Wait = "please wait";
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmService.cs
new file mode 100644
index 000000000..8e0c82186
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmService.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models.Events;
+using JWT;
+using JWT.Algorithms;
+using JWT.Exceptions;
+using JWT.Serializers;
+using Rampastring.Tools;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+
+public class QmService : IDisposable
+{
+ private readonly QmUserSettingsService userSettingsService;
+ private readonly QmApiService apiService;
+ private readonly QmUserSettings qmUserSettings;
+ private readonly QmData qmData;
+
+ private static QmService _instance;
+
+ private QmService()
+ {
+ userSettingsService = new QmUserSettingsService();
+ apiService = new QmApiService();
+ qmUserSettings = userSettingsService.GetSettings();
+ qmData = new QmData();
+ }
+
+ public event EventHandler QmEvent;
+
+ public static QmService GetInstance() => _instance ??= new QmService();
+
+ public IEnumerable GetUserAccounts() => qmData?.UserAccounts;
+
+ public QmLadder GetLadderForId(int ladderId) => qmData.Ladders.FirstOrDefault(l => l.Id == ladderId);
+
+ public string GetCachedEmail() => qmUserSettings.Email;
+
+ public string GetCachedLadder() => qmUserSettings.Ladder;
+
+ public bool IsServerAvailable() => apiService.IsServerAvailable();
+
+ ///
+ /// Login process to cncnet.
+ ///
+ /// The email for login.
+ /// The password for login.
+ public void LoginAsync(string email, string password) =>
+ ExecuteLoginRequest(async () =>
+ {
+ QmAuthData authData = await apiService.LoginAsync(email, password);
+ FinishLogin(authData, email);
+ });
+
+ ///
+ /// Attempts to refresh an existing auth tokenl.
+ ///
+ public void RefreshAsync() =>
+ ExecuteLoginRequest(async () =>
+ {
+ QmAuthData authData = await apiService.RefreshAsync();
+ FinishLogin(authData);
+ });
+
+ ///
+ /// Simply clear all auth data from our settings.
+ ///
+ public void Logout()
+ {
+ ClearAuthData();
+ QmEvent?.Invoke(this, new QmLogoutEvent());
+ }
+
+ public bool IsLoggedIn()
+ {
+ if (qmUserSettings.AuthData == null)
+ return false;
+
+ try
+ {
+ DecodeToken(qmUserSettings.AuthData.Token);
+ }
+ catch (TokenExpiredException)
+ {
+ Logger.Log(QmStrings.TokenExpiredError);
+ return false;
+ }
+ catch (Exception e)
+ {
+ Logger.Log(e.StackTrace);
+ return false;
+ }
+
+ apiService.SetToken(qmUserSettings.AuthData.Token);
+
+ return true;
+ }
+
+ public void SetLadder(QmLadder ladder)
+ {
+ qmUserSettings.Ladder = ladder?.Abbreviation;
+ userSettingsService.SaveSettings();
+ QmEvent?.Invoke(this, new QmLadderSelectedEvent(ladder));
+ }
+
+ public void LoadLaddersAndUserAccountsAsync() =>
+ ExecuteRequest(new QmLoadingLaddersAndUserAccountsEvent(), async () =>
+ {
+ Task> loadLaddersTask = apiService.LoadLaddersAsync();
+ Task> loadUserAccountsTask = apiService.LoadUserAccountsAsync();
+
+ await Task.WhenAll(loadLaddersTask, loadUserAccountsTask);
+ qmData.Ladders = loadLaddersTask.Result.ToList();
+ qmData.UserAccounts = loadUserAccountsTask.Result.ToList();
+
+ if (!qmData.Ladders.Any())
+ {
+ QmEvent?.Invoke(this, new QmErrorMessageEvent(QmStrings.NoLaddersFoundError));
+ return;
+ }
+
+ if (!qmData.UserAccounts.Any())
+ {
+ QmEvent?.Invoke(this, new QmErrorMessageEvent(QmStrings.NoUserAccountsFoundError));
+ return;
+ }
+
+ QmEvent?.Invoke(this, new QmLaddersAndUserAccountsEvent(qmData.Ladders, qmData.UserAccounts));
+ });
+
+ public void LoadLadderMapsForAbbrAsync(string ladderAbbr) =>
+ ExecuteRequest(new QmLoadingLadderMapsEvent(), async () =>
+ {
+ IEnumerable ladderMaps = await apiService.LoadLadderMapsForAbbrAsync(ladderAbbr);
+ QmEvent?.Invoke(this, new QmLadderMapsEvent(ladderMaps));
+ });
+
+ public void LoadLadderStatsForAbbrAsync(string ladderAbbr) =>
+ ExecuteRequest(new QmLoadingLadderStatsEvent(), async () =>
+ {
+ QmLadderStats ladderStats = await apiService.LoadLadderStatsForAbbrAsync(ladderAbbr);
+ QmEvent?.Invoke(this, new QmLadderStatsEvent(ladderStats));
+ });
+
+ public void RequestMatchAsync(QmRequest qmRequest) =>
+ ExecuteRequest(new QmRequestingMatchEvent(CancelRequestMatchAsync), async () =>
+ {
+ QmRequestResponse response = await apiService.QuickMatchRequestAsync(qmRequest);
+ QmEvent?.Invoke(this, new QmRequestResponseEvent(response));
+ });
+
+ public void Dispose()
+ {
+ apiService.Dispose();
+ }
+
+ ///
+ /// We only need to verify the expiration date of the token so that we can refresh or request a new one if it is expired.
+ /// We do not need to worry about the signature. The API will handle that validation when the token is used.
+ ///
+ /// The token to be decoded.
+ private static void DecodeToken(string token)
+ {
+ IJsonSerializer serializer = new JsonNetSerializer();
+ IDateTimeProvider provider = new UtcDateTimeProvider();
+ ValidationParameters validationParameters = ValidationParameters.Default;
+ validationParameters.ValidateSignature = false;
+ IJwtValidator validator = new JwtValidator(serializer, provider, validationParameters);
+ IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
+ IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); // symmetric
+ IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder, algorithm);
+
+ decoder.Decode(token, "nokey", verify: true);
+ }
+
+ private void ClearAuthData()
+ {
+ userSettingsService.ClearAuthData();
+ userSettingsService.SaveSettings();
+ apiService.SetToken(null);
+ }
+
+ private void CancelRequestMatchAsync() =>
+ ExecuteRequest(new QmCancelingRequestMatchEvent(), async () =>
+ {
+ var qmRequest = new QmRequest { Type = QmRequestTypes.Quit };
+ QmRequestResponse response = await apiService.QuickMatchRequestAsync(qmRequest);
+ QmEvent?.Invoke(this, new QmRequestResponseEvent(response));
+ });
+
+ private void ExecuteLoginRequest(Func func) =>
+ ExecuteRequest(new QmLoggingInEvent(), async () =>
+ {
+ await func();
+ QmEvent?.Invoke(this, new QmLoginEvent());
+ });
+
+ private void ExecuteRequest(QmEvent qmEvent, Func requestAction)
+ {
+ QmEvent?.Invoke(this, qmEvent);
+ Task.Run(async () =>
+ {
+ try
+ {
+ await requestAction();
+ }
+ catch (Exception e)
+ {
+ QmEvent?.Invoke(this, new QmErrorMessageEvent((e as ClientException)?.Message ?? QmStrings.UnknownError));
+ }
+ });
+ }
+
+ private void FinishLogin(QmAuthData authData, string email = null)
+ {
+ qmUserSettings.AuthData = authData;
+ qmUserSettings.Email = email ?? qmUserSettings.Email;
+ userSettingsService.SaveSettings();
+
+ apiService.SetToken(authData.Token);
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettingsService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettingsService.cs
new file mode 100644
index 000000000..82ac7fcd2
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettingsService.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using ClientCore;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using Rampastring.Tools;
+using Rampastring.XNAUI;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmSettingsService
+ {
+ private static QmSettingsService Instance;
+ private static readonly string SettingsFile = ClientConfiguration.Instance.QuickMatchPath;
+
+ private const string BasicSectionKey = "Basic";
+ private const string SoundsSectionKey = "Sounds";
+ private const string HeaderLogosSectionKey = "HeaderLogos";
+
+ private const string BaseUrlKey = "BaseUrl";
+ private const string LoginUrlKey = "LoginUrl";
+ private const string RefreshUrlKey = "RefreshUrl";
+ private const string ServerStatusUrlKey = "ServerStatusUrl";
+ private const string GetUserAccountsUrlKey = "GetUserAccountsUrl";
+ private const string GetLaddersUrlKey = "GetLaddersUrl";
+ private const string GetLadderMapsUrlKey = "GetLadderMapsUrl";
+
+ private const string MatchFoundSoundFileKey = "MatchFoundSoundFile";
+
+ private QmSettings qmSettings;
+
+ private QmSettingsService()
+ {
+ }
+
+ public static QmSettingsService GetInstance() => Instance ??= new QmSettingsService();
+
+ public QmSettings GetSettings() => qmSettings ??= LoadSettings();
+
+ private static QmSettings LoadSettings()
+ {
+ var settings = new QmSettings();
+ if (!File.Exists(SettingsFile))
+ SaveSettings(settings); // init the settings file
+
+ var iniFile = new IniFile(SettingsFile);
+ LoadBasicSettings(iniFile, settings);
+ LoadSoundSettings(iniFile, settings);
+ LoadHeaderLogoSettings(iniFile, settings);
+
+ return settings;
+ }
+
+ private static void LoadBasicSettings(IniFile iniFile, QmSettings settings)
+ {
+ IniSection basicSection = iniFile.GetSection(BasicSectionKey);
+ if (basicSection == null)
+ return;
+
+ settings.BaseUrl = basicSection.GetStringValue(BaseUrlKey, QmSettings.DefaultBaseUrl);
+ settings.LoginUrl = basicSection.GetStringValue(LoginUrlKey, QmSettings.DefaultLoginUrl);
+ settings.RefreshUrl = basicSection.GetStringValue(RefreshUrlKey, QmSettings.DefaultRefreshUrl);
+ settings.ServerStatusUrl = basicSection.GetStringValue(ServerStatusUrlKey, QmSettings.DefaultServerStatusUrl);
+ settings.GetUserAccountsUrl = basicSection.GetStringValue(GetUserAccountsUrlKey, QmSettings.DefaultGetUserAccountsUrl);
+ settings.GetLaddersUrl = basicSection.GetStringValue(GetLaddersUrlKey, QmSettings.DefaultGetLaddersUrl);
+ settings.GetLadderMapsUrlFormat = basicSection.GetStringValue(GetLadderMapsUrlKey, QmSettings.DefaultGetLadderMapsUrl);
+ }
+
+ private static void LoadSoundSettings(IniFile iniFile, QmSettings settings)
+ {
+ IniSection soundsSection = iniFile.GetSection(SoundsSectionKey);
+ if (soundsSection == null)
+ return;
+
+ string matchFoundSoundFile = soundsSection.GetStringValue(MatchFoundSoundFileKey, null);
+ if (matchFoundSoundFile == null)
+ return;
+
+ matchFoundSoundFile = SafePath.CombineFilePath("Resources", matchFoundSoundFile);
+ if (File.Exists(matchFoundSoundFile))
+ settings.MatchFoundSoundFile = matchFoundSoundFile;
+ }
+
+ private static void LoadHeaderLogoSettings(IniFile iniFile, QmSettings settings)
+ {
+ IniSection headerLogosSection = iniFile.GetSection(HeaderLogosSectionKey);
+ if (headerLogosSection == null)
+ return;
+
+ foreach (KeyValuePair keyValuePair in headerLogosSection.Keys.Where(keyValuePair => AssetLoader.AssetExists(keyValuePair.Value)))
+ settings.HeaderLogos.Add(keyValuePair.Key, AssetLoader.LoadTexture(keyValuePair.Value));
+ }
+
+ public static void SaveSettings(QmSettings settings)
+ {
+ var iniFile = new IniFile();
+ var basicSection = new IniSection(BasicSectionKey);
+ basicSection.AddKey(BaseUrlKey, settings.BaseUrl);
+ basicSection.AddKey(LoginUrlKey, settings.LoginUrl);
+ basicSection.AddKey(RefreshUrlKey, settings.RefreshUrl);
+ basicSection.AddKey(ServerStatusUrlKey, settings.ServerStatusUrl);
+ basicSection.AddKey(GetUserAccountsUrlKey, settings.GetUserAccountsUrl);
+ basicSection.AddKey(GetLaddersUrlKey, settings.GetLaddersUrl);
+ basicSection.AddKey(GetLadderMapsUrlKey, settings.GetLadderMapsUrlFormat);
+
+ iniFile.AddSection(basicSection);
+ iniFile.WriteIniFile(SettingsFile);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStrings.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStrings.cs
new file mode 100644
index 000000000..aa882acba
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStrings.cs
@@ -0,0 +1,66 @@
+using Localization;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+
+public static class QmStrings
+{
+ // Error Messages
+ public static string TokenExpiredError => "QuickMatch token is expired".L10N("QM:Error:TokenExpired");
+
+ public static string NoLaddersFoundError => "No quick match ladders currently found.".L10N("QM:Error:NoLaddersFound");
+
+ public static string NoUserAccountsFoundError => "No user accounts found in quick match. Are you registered for this month?".L10N("QM:Error:NoUserAccountsFound");
+
+ public static string LoadingLadderStatsError => "Error loading ladder stats".L10N("QM:Error:LoadingLadderStats");
+
+ public static string LoadingLadderMapsError => "Error loading ladder maps".L10N("QM:Error:LoadingLadderMaps");
+
+ public static string ServerUnreachableError => "Server unreachable".L10N("QM:Error:ServerUnreachable");
+
+ public static string InvalidUsernamePasswordError => "Invalid username/password".L10N("QM:Error:InvalidUsernamePassword");
+
+ public static string NoSideSelectedError => "No side selected".L10N("QM:Error:NoSideSelected");
+
+ public static string NoLadderSelectedError => "No ladder selected".L10N("QM:Error:NoLadderSelected");
+
+ public static string UnknownError => "Unknown error occurred".L10N("QM:Error:Unknown");
+ public static string LoggingInUnknownError => "Error logging in".L10N("QM:Error:LoggingInUnknown");
+
+ public static string CancelingMatchRequestError => "Error canceling match request".L10N("QM:Error:CancelingMatchRequest");
+
+ public static string RequestingMatchUnknownError => "Error requesting match".L10N("QM:Error:RequestingMatchUnknown");
+
+ public static string LoadingLaddersAndAccountsUnknownError => "Error loading ladders and accounts...".L10N("QM:Error:LoadingLaddersAndAccountsUnknown");
+
+ // Error Messages Formatted
+ public static string LoadingLadderMapsErrorFormat => "Error loading ladder maps: {0}".L10N("QM:Error:LoadingLadderMapsFormat");
+
+ public static string LoadingLadderStatsErrorFormat => "Error loading ladder stats: {0}".L10N("QM:Error:LoadingLadderStatsFormat");
+
+ public static string LoadingUserAccountsErrorFormat => "Error loading user accounts: {0}".L10N("QM:Error:LoadingUserAccountsFormat");
+
+ public static string LoadingLaddersErrorFormat => "Error loading ladders: {0}".L10N("QM:Error:LoadingLaddersFormat");
+
+ public static string LoggingInUnknownErrorFormat => "Error logging in: {0}, {1}".L10N("QM:Error:LoggingInUnknownFormat");
+
+ public static string RequestingMatchErrorFormat => "Error requesting match: {0}".L10N("QM:Error:RequestingMatchFormat");
+
+ // UI Messages
+ public static string GenericErrorTitle => "Error".L10N("QM:UI:GenericErrorTitle");
+
+ public static string LogoutConfirmation => "Are you sure you want to log out?".L10N("QM:UI:LogoutConfirmation");
+
+ public static string ConfirmationCaption => "Confirmation".L10N("QM:UI:ConfirmationCaption");
+
+ public static string RequestingMatchStatus => "Requesting match".L10N("QM:UI:RequestingMatchStatus");
+
+ public static string CancelingMatchRequestStatus => "Canceling match request".L10N("QM:UI:CancelingMatchRequest");
+
+ public static string LoadingStats => "Loading stats...".L10N("QM:UI:LoadingStats");
+
+ public static string LoadingLaddersAndAccountsStatus => "Loading ladders and accounts".L10N("QM:UI:LoadingLaddersAndAccountsStatus");
+
+ public static string LoadingLadderMapsStatus => "Loading ladder maps".L10N("QM:UI:LoadingLadderMapsStatus");
+
+ public static string LoggingInStatus => "Logging in".L10N("QM:UI:LoggingInStatus");
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettingsService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettingsService.cs
new file mode 100644
index 000000000..816066924
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettingsService.cs
@@ -0,0 +1,80 @@
+using System;
+using System.IO;
+using ClientCore;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch.Models;
+using Newtonsoft.Json;
+using Rampastring.Tools;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmUserSettingsService
+ {
+ private static readonly string SettingsFile = $"{ProgramConstants.ClientUserFilesPath}QuickMatchSettings.ini";
+
+ private const string BasicSectionKey = "Basic";
+ private const string AuthDataKey = "AuthData";
+ private const string EmailKey = "Email";
+ private const string LadderKey = "Ladder";
+
+ private QmUserSettings qmUserSettings;
+
+ public QmUserSettings GetSettings() => qmUserSettings ??= LoadSettings();
+
+ private static QmUserSettings LoadSettings()
+ {
+ var settings = new QmUserSettings();
+ if (!File.Exists(SettingsFile))
+ return settings;
+
+ var iniFile = new IniFile(SettingsFile);
+ LoadBasicSettings(iniFile, settings);
+
+ return settings;
+ }
+
+ private static void LoadBasicSettings(IniFile iniFile, QmUserSettings settings)
+ {
+ IniSection basicSection = iniFile.GetSection(BasicSectionKey);
+ if (basicSection == null)
+ return;
+
+ settings.AuthData = GetAuthData(basicSection);
+ settings.Email = basicSection.GetStringValue(EmailKey, null);
+ settings.Ladder = basicSection.GetStringValue(LadderKey, null);
+ }
+
+ private static QmAuthData GetAuthData(IniSection section)
+ {
+ if (!section.KeyExists(AuthDataKey))
+ return null;
+
+ string authDataValue = section.GetStringValue(AuthDataKey, null);
+ if (string.IsNullOrEmpty(authDataValue))
+ return null;
+
+ try
+ {
+ return JsonConvert.DeserializeObject(authDataValue);
+ }
+ catch (Exception e)
+ {
+ Logger.Log(e.StackTrace);
+ return null;
+ }
+ }
+
+ public void ClearAuthData() => qmUserSettings.AuthData = null;
+
+ public void SaveSettings()
+ {
+ var iniFile = new IniFile();
+ var basicSection = new IniSection(BasicSectionKey);
+ basicSection.AddKey(EmailKey, qmUserSettings.Email ?? string.Empty);
+ basicSection.AddKey(LadderKey, qmUserSettings.Ladder ?? string.Empty);
+ basicSection.AddKey(AuthDataKey, JsonConvert.SerializeObject(qmUserSettings.AuthData));
+
+ iniFile.AddSection(basicSection);
+ iniFile.WriteIniFile(SettingsFile);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/MapLoader.cs b/DXMainClient/Domain/Multiplayer/MapLoader.cs
index 67020840f..8f1ab0b7a 100644
--- a/DXMainClient/Domain/Multiplayer/MapLoader.cs
+++ b/DXMainClient/Domain/Multiplayer/MapLoader.cs
@@ -21,6 +21,8 @@ public class MapLoader
private const string GameModeAliasesSection = "GameModeAliases";
private const int CurrentCustomMapCacheVersion = 1;
+ private static MapLoader Instance;
+
///
/// List of game modes.
///
@@ -46,6 +48,12 @@ public class MapLoader
///
private string[] AllowedGameModes = ClientConfiguration.Instance.AllowedCustomGameModes.Split(',');
+ private MapLoader()
+ {
+ }
+
+ public static MapLoader GetInstance() => Instance ?? (Instance = new MapLoader());
+
///
/// Loads multiplayer map info asynchonously.
///
@@ -331,5 +339,8 @@ private void AddMapToGameModes(Map map, bool enableLogging)
}
}
}
+
+ public Map GetMapForSHA(string sha1)
+ => GameModeMaps.Select(gmm => gmm.Map).FirstOrDefault(m => m.SHA1 == sha1);
}
}
diff --git a/Docs/INISystem.md b/Docs/INISystem.md
index 1269a1974..a40786983 100644
--- a/Docs/INISystem.md
+++ b/Docs/INISystem.md
@@ -7,8 +7,8 @@ The `[ParserConstants]` section of the `GlobalThemeSettings.ini` file contains c
### Predefined System Constants
-`RESOLUTION_WIDTH`: the width of the window when it is initialized
-`RESOLUTION_HEIGHT`: the height of the window when it is initialized
+`RESOLUTION_WIDTH`: the width of the window when it is initialized
+`RESOLUTION_HEIGHT`: the height of the window when it is initialized
### User Defined Constants
@@ -22,7 +22,7 @@ $X=MY_EXAMPLE_CONSTANT
```
_NOTE: Constants can only be used in [dynamic control properties](#dynamic-control-properties)_
-## Control properties:
+## Control properties:
Below lists basic and dynamic control properties. Ordering of properties is important. If there is a property that relies on the size of a control, the properties must set the size of that control first.
@@ -31,121 +31,121 @@ Basic control properties cannot use constants
#### XNAControl
-`X` = `{integer}` the X location of the control
-`Y` = `{integer}` the Y location of the control
-`Location` = `{comma separated integers}` the X and Y location of the control.
-`Width` = `{integer}` the Width of the control
-`Height` = `{integer}` the Height of the control
-`Size` = `{comma separated integers}` the Width and Height of the control.
-`Text` = `{string}` the text to display for the control (ex: buttons, labels, etc...)
-`Visible` = `{true/false or yes/no}` whether or not the control should be visible by default
-`Enabled` = `{true/false or yes/no}` whether or not the control should be enabled by default
-`DistanceFromRightBorder` = `{integer}` the distance of the right edge of this control from the right edge of its parent. This control MUST have a parent.
-`DistanceFromBottomBorder` = `{integer}` the distance of the bottom edge of this control from the bottom edge of its parent. This control MUST have a parent.
-`FillWidth` = `{integer}` this will set the width of this control to fill the parent/window MINUS this value, starting from the its X position
-`FillHeight` = `{integer}` this will set the height of this control to fill the parent/window MINUS this value, starting from the its Y position
-`DrawOrder`
-`UpdateOrder`
-`RemapColor`
+`X` = `{integer}` the X location of the control
+`Y` = `{integer}` the Y location of the control
+`Location` = `{comma separated integers}` the X and Y location of the control.
+`Width` = `{integer}` the Width of the control
+`Height` = `{integer}` the Height of the control
+`Size` = `{comma separated integers}` the Width and Height of the control.
+`Text` = `{string}` the text to display for the control (ex: buttons, labels, etc...)
+`Visible` = `{true/false or yes/no}` whether or not the control should be visible by default
+`Enabled` = `{true/false or yes/no}` whether or not the control should be enabled by default
+`DistanceFromRightBorder` = `{integer}` the distance of the right edge of this control from the right edge of its parent. This control MUST have a parent.
+`DistanceFromBottomBorder` = `{integer}` the distance of the bottom edge of this control from the bottom edge of its parent. This control MUST have a parent.
+`FillWidth` = `{integer}` this will set the width of this control to fill the parent/window MINUS this value, starting from the its X position
+`FillHeight` = `{integer}` this will set the height of this control to fill the parent/window MINUS this value, starting from the its Y position
+`DrawOrder`
+`UpdateOrder`
+`RemapColor`
#### XNAPanel
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`BorderColor`
-`DrawMode`
-`AlphaRate`
-`BackgroundTexture`
-`SolidColorBackgroundTexture`
-`DrawBorders`
-`Padding`
+`BorderColor`
+`DrawMode`
+`AlphaRate`
+`BackgroundTexture`
+`SolidColorBackgroundTexture`
+`DrawBorders`
+`Padding`
#### XNAExtraPanel
-_(inherits XNAPanel)_
+_(inherits [XNAPanel](#xnapanel))_
-`BackgroundTexture`
+`BackgroundTexture`
#### XNALabel
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`RemapColor`
-`TextColor`
-`FontIndex`
-`AnchorPoint`
-`TextAnchor`
-`TextShadowDistance`
+`RemapColor`
+`TextColor`
+`FontIndex`
+`AnchorPoint`
+`TextAnchor`
+`TextShadowDistance`
#### XNAButton
-_(inherits XNAControl)_
-
-`TextColorIdle`
-`TextColorHover`
-`HoverSoundEffect`
-`ClickSoundEffect`
-`AdaptiveText`
-`AlphaRate`
-`FontIndex`
-`IdleTexture`
-`HoverTexture`
-`TextShadowDistance`
+_(inherits [XNAControl](#xnacontrol))_
+
+`TextColorIdle`
+`TextColorHover`
+`HoverSoundEffect`
+`ClickSoundEffect`
+`AdaptiveText`
+`AlphaRate`
+`FontIndex`
+`IdleTexture`
+`HoverTexture`
+`TextShadowDistance`
#### XNAClientButton
-_(inherits XNAButton)_
+_(inherits [XNAButton](#xnabutton))_
-`MatchTextureSize`
+`MatchTextureSize`
#### XNALinkButton
-_(inherits XNAClientButton)_
+_(inherits [XNAClientButton](#xnaclientbutton))_
-`URL`
-`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
+`URL`
+`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
#### XNACheckbox
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`FontIndex`
-`IdleColor`
-`HighlightColor`
-`AlphaRate`
-`AllowChecking`
-`Checked`
+`FontIndex`
+`IdleColor`
+`HighlightColor`
+`AlphaRate`
+`AllowChecking`
+`Checked`
#### XNAClientCheckbox
-_(inherits XNACheckBox)_
+_(inherits [XNACheckBox](#xnacheckbox))_
`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
#### XNADropDown
-_(inherits XNAControl)_
-
-`OpenUp`
-`DropDownTexture`
-`DropDownOpenTexture`
-`ItemHeight`
-`ClickSoundEffect`
-`FontIndex`
-`BorderColor`
-`FocusColor`
-`BackColor`
-`~~DisabledItemColor~~`
-`OptionN`
+_(inherits [XNAControl](#xnacontrol))_
+
+`OpenUp`
+`DropDownTexture`
+`DropDownOpenTexture`
+`ItemHeight`
+`ClickSoundEffect`
+`FontIndex`
+`BorderColor`
+`FocusColor`
+`BackColor`
+`~~DisabledItemColor~~`
+`OptionN`
#### XNAClientDropDown
-_(inherits XNADropDown)_
+_(inherits [XNADropDown](#xnadropdown))_
-`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
+`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
#### XNATabControl
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`RemapColor`
-`TextColor`
-`TextColorDisabled`
-`RemoveTabIndexN`
+`RemapColor`
+`TextColor`
+`TextColorDisabled`
+`RemoveTabIndexN`
#### XNATextBox
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`MaximumTextLength`
+`MaximumTextLength`
### Basic Control Property Examples
```
@@ -176,49 +176,49 @@ Following controls are only available as children of `XNAOptionsPanel` and deriv
##### SettingCheckBox
_(inherits XNAClientCheckBox)_
-`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
-`WriteSettingValue` = `{true/false or yes/no}` enable to write a specific string value to setting INI key instead of the checked state of the checkbox. Defaults to `false`.
-`EnabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is checked.
-`DisabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is not checked.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
-`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
-`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
+`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
+`WriteSettingValue` = `{true/false or yes/no}` enable to write a specific string value to setting INI key instead of the checked state of the checkbox. Defaults to `false`.
+`EnabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is checked.
+`DisabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is not checked.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
+`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
##### FileSettingCheckBox
_(inherits XNAClientCheckBox)_
-`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
-`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
-`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
-`CheckAvailability` = `{true/false or yes/no}` if set, whether or not the checkbox can be (un)checked depends on if the files to copy are actually present. Defaults to `false`.
-`ResetUnavailableValue` = `{true/false or yes/no}` if set together with `CheckAvailability`, checkbox set to a value that is unavailable will be reset back to `DefaultValue`. Defaults to `false`.
-`EnabledFileN` = `{comma-separated strings}` files to copy if checkbox is checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
-`DisabledFileN` = `{comma-separated strings}` files to copy if checkbox is not checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
+`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
+`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
+`CheckAvailability` = `{true/false or yes/no}` if set, whether or not the checkbox can be (un)checked depends on if the files to copy are actually present. Defaults to `false`.
+`ResetUnavailableValue` = `{true/false or yes/no}` if set together with `CheckAvailability`, checkbox set to a value that is unavailable will be reset back to `DefaultValue`. Defaults to `false`.
+`EnabledFileN` = `{comma-separated strings}` files to copy if checkbox is checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
+`DisabledFileN` = `{comma-separated strings}` files to copy if checkbox is not checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
##### SettingDropDown
_(inherits XNAClientDropDown)_
-`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
-`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_SelectedIndex`.
-`WriteSettingValue` = `{true/false or yes/no}` enable to write selected item value to the setting INI key instead of the checked state of the checkbox. Defaults to `false`.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
+`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_SelectedIndex`.
+`WriteSettingValue` = `{true/false or yes/no}` enable to write selected item value to the setting INI key instead of the checked state of the checkbox. Defaults to `false`.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
##### FileSettingDropDown
_(inherits XNAClientDropDown)_
-`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
-`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_SelectedIndex`.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
-`ItemXFileN` = `{comma-separated strings}` files to copy when dropdown item X is selected. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
+`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
+`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_SelectedIndex`.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`ItemXFileN` = `{comma-separated strings}` files to copy when dropdown item X is selected. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
##### Appendix: File Operation Options
@@ -234,11 +234,11 @@ Dynamic Control Properties CAN use constants
These can ONLY be used in parent controls that inherit the `INItializableWindow` class
-`$X` = ``{integer}`` the X location of the control
-`$Y` = ``{integer}`` the Y location of the control
-`$Width` = ``{integer}`` the Width of the control
-`$Height` = ``{integer}`` the Height of the control
-`$TextAnchor`
+`$X` = ``{integer}`` the X location of the control
+`$Y` = ``{integer}`` the Y location of the control
+`$Width` = ``{integer}`` the Width of the control
+`$Height` = ``{integer}`` the Height of the control
+`$TextAnchor`
### Dynamic Control Property Examples
```
diff --git a/build/AfterPublish.targets b/build/AfterPublish.targets
index d415b5031..73febddf4 100644
--- a/build/AfterPublish.targets
+++ b/build/AfterPublish.targets
@@ -24,6 +24,7 @@
+
@@ -48,6 +49,7 @@
+
@@ -68,6 +70,7 @@
+
@@ -117,4 +120,4 @@
-
\ No newline at end of file
+