diff --git a/scripts/build.ps1 b/scripts/build.ps1 index eedb8e00..5f8f6d7b 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -18,8 +18,13 @@ param ( ) # Functions -Function Find-MsBuild([int] $MaxVersion = 2022) +Function Find-MsBuild([int] $MaxVersion = 2026) { + $agent2026Path = "$Env:programfiles\Microsoft Visual Studio\18\BuildTools\MSBuild\Current\Bin\msbuild.exe" + $ent2026Path = "$Env:programfiles\Microsoft Visual Studio\18\Enterprise\MSBuild\Current\Bin\msbuild.exe" + $pro2026Path = "$Env:programfiles\Microsoft Visual Studio\18\Professional\MSBuild\Current\Bin\msbuild.exe" + $community2026Path = "$Env:programfiles\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\msbuild.exe" + $agent2022Path = "$Env:programfiles\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\msbuild.exe" $ent2022Path = "$Env:programfiles\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\msbuild.exe" $pro2022Path = "$Env:programfiles\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\msbuild.exe" @@ -39,7 +44,12 @@ Function Find-MsBuild([int] $MaxVersion = 2022) $fallback2013Path = "${Env:ProgramFiles(x86)}\MSBuild\12.0\Bin\MSBuild.exe" $fallbackPath = "C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe" - If ((2022 -le $MaxVersion) -And (Test-Path $agent2022Path)) { return $agent2022Path } + If ((2026 -le $MaxVersion) -And (Test-Path $agent2026Path)) { return $agent2026Path } + If ((2026 -le $MaxVersion) -And (Test-Path $ent2026Path)) { return $ent2026Path } + If ((2026 -le $MaxVersion) -And (Test-Path $pro2026Path)) { return $pro2026Path } + If ((2026 -le $MaxVersion) -And (Test-Path $community2026Path)) { return $community2026Path } + + If ((2022 -le $MaxVersion) -And (Test-Path $agent2022Path)) { return $agent2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $ent2022Path)) { return $ent2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $pro2022Path)) { return $pro2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $community2022Path)) { return $community2022Path } @@ -106,12 +116,56 @@ If ($Update) Write-Host "[CSM Update Script] You have specified the -Update flag. The script will now update the local assemblies to the installed version under Cities: Skylines." Write-Host "[CSM Update Script] Please Note: This may break the mod if any major game changes have occured." - # Get the steam directory + # Get the game directory If ($GameDirectory -eq "") { - $GameDirectory = Read-Host "[CSM Update Script] Please enter your game folder directory. For example, for Windows, 'C:\Program Files\Steam\steamapps\common\Cities_Skylines' for Steam or 'C:\Program Files\Epic Games\CitiesSkylines' for Epic Games." + # Common default install locations + $DefaultPaths = @() + If ($IsMacOS) + { + $DefaultPaths += @{ Label = "Steam"; Path = "~/Library/Application Support/Steam/steamapps/common/Cities_Skylines" } + } + ElseIf ($IsLinux) + { + $DefaultPaths += @{ Label = "Steam"; Path = "~/.local/share/Steam/steamapps/common/Cities_Skylines" } + } + Else + { + $DefaultPaths += @{ Label = "Steam"; Path = "C:\Program Files (x86)\Steam\steamapps\common\Cities_Skylines" } + $DefaultPaths += @{ Label = "Epic Games"; Path = "C:\Program Files\Epic Games\CitiesSkylines" } + } + + # Only offer the ones that actually exist + $FoundPaths = @($DefaultPaths | Where-Object { Test-Path -Path $_.Path }) + + If ($FoundPaths.Count -gt 0) + { + Write-Host "[CSM Update Script] Found the following Cities: Skylines installation(s):" + For ($i = 0; $i -lt $FoundPaths.Count; $i++) + { + Write-Host "[CSM Update Script] $($i + 1)) $($FoundPaths[$i].Path) ($($FoundPaths[$i].Label))" + } + $OtherOption = $FoundPaths.Count + 1 + Write-Host "[CSM Update Script] $($OtherOption)) Enter a different directory" + + $Selection = Read-Host "[CSM Update Script] Please choose an option (1-$($OtherOption))" + + $SelectionIndex = 0 + If ([int]::TryParse($Selection, [ref]$SelectionIndex) -and $SelectionIndex -ge 1 -and $SelectionIndex -le $FoundPaths.Count) + { + $GameDirectory = $FoundPaths[$SelectionIndex - 1].Path + } + Else + { + $GameDirectory = Read-Host "[CSM Update Script] Please enter your game folder directory." + } + } + Else + { + $GameDirectory = Read-Host "[CSM Update Script] Please enter your game folder directory. For example, for Windows, 'C:\Program Files\Steam\steamapps\common\Cities_Skylines' for Steam or 'C:\Program Files\Epic Games\CitiesSkylines' for Epic Games." + } } - Else + Else { Write-Host "[CSM Update Script] Game directory: $($GameDirectory)" } diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 new file mode 100644 index 00000000..6b43cc91 --- /dev/null +++ b/scripts/deploy.ps1 @@ -0,0 +1,69 @@ +#!/bin/pwsh +######################################################################### +# This script copies the locally built CSM mod .dll files to a remote # +# machine's Cities: Skylines mod folder (e.g. deploying from a Windows # +# dev box to a Mac for testing). # +######################################################################### + +# Params that can be passed in +param ( + [string]$RemoteUser, + [string]$RemoteHost = "varuns-macbook-pro", + [string]$RemoteOS = "mac", + [string]$SourceDirectory = "..\src\csm\bin\Release", + [string]$RemoteModDirectory = "Default" +) + +if ([string]::IsNullOrEmpty($RemoteUser)) +{ + Write-Host "[CSM Deploy Script] You must specify -RemoteUser (the SSH username on $($RemoteHost))." + exit 1 +} + +If ($RemoteModDirectory -eq "Default") +{ + Switch ($RemoteOS.ToLower()) + { + "mac" { $RemoteModDirectory = "~/Library/Application Support/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } + "linux" { $RemoteModDirectory = "~/.local/share/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } + default + { + Write-Host "[CSM Deploy Script] Unknown -RemoteOS '$($RemoteOS)'. Expected 'mac' or 'linux', or pass -RemoteModDirectory explicitly." + exit 1 + } + } +} + +$RemoteTarget = "$($RemoteUser)@$($RemoteHost)" + +Write-Host "[CSM Deploy Script] Source directory: $($SourceDirectory)" +Write-Host "[CSM Deploy Script] Remote target: $($RemoteTarget)" +Write-Host "[CSM Deploy Script] Remote mod directory: $($RemoteModDirectory)" + +# Make sure there is something to deploy +$Dlls = Get-ChildItem -Path $SourceDirectory -Filter "*.dll" -ErrorAction SilentlyContinue +If (-not $Dlls) +{ + Write-Host "[CSM Deploy Script] No .dll files found in $($SourceDirectory). Did you run build.ps1 -Build first?" + exit 1 +} + +# Make sure the remote mod directory exists +Write-Host "[CSM Deploy Script] Ensuring remote mod directory exists..." +ssh $RemoteTarget "mkdir -p '$($RemoteModDirectory)'" +If ($LASTEXITCODE -ne 0) +{ + Write-Host "[CSM Deploy Script] Failed to create/verify the remote mod directory. Check SSH connectivity to $($RemoteTarget)." + exit $LASTEXITCODE +} + +# Copy the dll files across +Write-Host "[CSM Deploy Script] Copying $($Dlls.Count) file(s)..." +scp "$($SourceDirectory)\*.dll" "$($RemoteTarget):$($RemoteModDirectory)/" +If ($LASTEXITCODE -ne 0) +{ + Write-Host "[CSM Deploy Script] Deploy failed!" + exit $LASTEXITCODE +} + +Write-Host "[CSM Deploy Script] Done! Files deployed to $($RemoteTarget):$($RemoteModDirectory)" diff --git a/scripts/run-gs.ps1 b/scripts/run-gs.ps1 new file mode 100644 index 00000000..6e30924e --- /dev/null +++ b/scripts/run-gs.ps1 @@ -0,0 +1,27 @@ +#!/bin/pwsh +######################################################################### +# This script runs the CSM.GS server locally (the NAT relay / public # +# server list service). Useful for testing hosting a server against a # +# self-hosted API server instead of the official one. # +######################################################################### + +# Params that can be passed in +param ( + [int]$Port = 4240, + [int]$HttpPort = 4241, + [string]$Configuration = "Debug" +) + +Write-Host "[CSM GS Script] Starting the CSM.GS server locally." +Write-Host "[CSM GS Script] UDP (NAT relay) port: $($Port)" +Write-Host "[CSM GS Script] HTTP (public server list) port: $($HttpPort)" +Write-Host "[CSM GS Script] Point your game's 'CSM API Server' setting at this machine, with matching ports, to use it." + +$env:GS_PORT = $Port +$env:GS_HTTP_PORT = $HttpPort + +# CSM.GS targets net7.0, which may not be installed if only a newer .NET is +# present. Roll forward to whatever major version is available. +$env:DOTNET_ROLL_FORWARD = "LatestMajor" + +dotnet run --project "$PSScriptRoot/../src/gs/CSM.GS.csproj" --configuration $Configuration diff --git a/src/csm/CSM.csproj b/src/csm/CSM.csproj index 6cdcf716..909754b7 100644 --- a/src/csm/CSM.csproj +++ b/src/csm/CSM.csproj @@ -107,10 +107,15 @@ + + + + + @@ -155,6 +160,7 @@ + @@ -167,6 +173,7 @@ + diff --git a/src/csm/GS/Commands/Handler/ApiServer/ServerListRequestHandler.cs b/src/csm/GS/Commands/Handler/ApiServer/ServerListRequestHandler.cs new file mode 100644 index 00000000..1d1d8757 --- /dev/null +++ b/src/csm/GS/Commands/Handler/ApiServer/ServerListRequestHandler.cs @@ -0,0 +1,12 @@ +using CSM.GS.Commands.Data.ApiServer; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListRequestHandler : ApiCommandHandler + { + protected override void Handle(ServerListRequestCommand command) + { + // Do nothing, this is a packet for the global server + } + } +} diff --git a/src/csm/GS/Commands/Handler/ApiServer/ServerListResultHandler.cs b/src/csm/GS/Commands/Handler/ApiServer/ServerListResultHandler.cs new file mode 100644 index 00000000..da75584f --- /dev/null +++ b/src/csm/GS/Commands/Handler/ApiServer/ServerListResultHandler.cs @@ -0,0 +1,13 @@ +using CSM.GS.Commands.Data.ApiServer; +using CSM.Panels; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListResultHandler : ApiCommandHandler + { + protected override void Handle(ServerListResultCommand command) + { + PanelManager.GetPanel()?.OnServerListReceived(command.Servers ?? new PublicServerEntry[0]); + } + } +} diff --git a/src/csm/Helpers/SteamHelpers.cs b/src/csm/Helpers/SteamHelpers.cs index e53eaf3e..9c4534c9 100644 --- a/src/csm/Helpers/SteamHelpers.cs +++ b/src/csm/Helpers/SteamHelpers.cs @@ -5,11 +5,8 @@ using System.Threading; using ColossalFramework; using ColossalFramework.PlatformServices; -using ColossalFramework.Threading; using CSM.API; using CSM.Helpers.Steamworks; -using CSM.Networking; -using CSM.Networking.Config; using CSM.Panels; namespace CSM.Helpers @@ -135,34 +132,7 @@ private void JoinFromSteam(string token, bool checkLoadingManager) return; } - Log.Info("Join request for " + token); - - JoinGamePanel join = PanelManager.ShowPanel(); - join.SetConnecting(); - - MultiplayerManager.Instance.CurrentClient.StartMainMenuEventProcessor(); - MultiplayerManager.Instance.ConnectToServer(new ClientConfig(token, PlatformService.personaName), - (success) => - { - if (success) - { - // See WorldTransferHandler for actual loading - ThreadHelper.dispatcher.Dispatch(() => - { - MultiplayerManager.Instance.BlockGameFirstJoin(); - PanelManager.HidePanel(); - }); - } - else - { - ThreadHelper.dispatcher.Dispatch(() => - { - JoinGamePanel panel = PanelManager.ShowPanel(); - panel.FillFieldsOnError(token, PlatformService.personaName, - MultiplayerManager.Instance.CurrentClient.ConnectionMessage); - }); - } - }); + JoinGamePanel.JoinByToken(token, PlatformService.personaName); } public void Shutdown() diff --git a/src/csm/Injections/MainMenuHandler.cs b/src/csm/Injections/MainMenuHandler.cs index 8758053a..49ccbd91 100644 --- a/src/csm/Injections/MainMenuHandler.cs +++ b/src/csm/Injections/MainMenuHandler.cs @@ -37,7 +37,7 @@ public static void CheckForUpdate(bool alwaysShowInfo) { try { - string latest = new CSMWebClient().DownloadString($"http://{CSM.Settings.ApiServer}/api/version"); + string latest = new CSMWebClient().DownloadString($"http://{CSM.Settings.ApiServer}:{CSM.Settings.ApiServerHttpPort}/api/version"); latest = latest.Substring(1); string[] versionParts = latest.Split('.'); Version latestVersion = new Version(int.Parse(versionParts[0]), int.Parse(versionParts[1])); diff --git a/src/csm/Networking/Config/ServerConfig.cs b/src/csm/Networking/Config/ServerConfig.cs index 242fb97b..9dea7d7c 100644 --- a/src/csm/Networking/Config/ServerConfig.cs +++ b/src/csm/Networking/Config/ServerConfig.cs @@ -16,13 +16,18 @@ public class ServerConfig /// The optional password for this server. /// The maximum amount of players that can connect to the server. /// Whether automatic port forwarding (UPnP) should be enabled. - public ServerConfig(int port, string username, string password, int maxPlayers, bool enablePortForwarding) + /// The display name of the server, shown in the public server list. + /// Whether this server should be listed in the public server list. + public ServerConfig(int port, string username, string password, int maxPlayers, bool enablePortForwarding, + string name = "", bool listPublicly = false) { Port = port; Username = username; Password = password; MaxPlayers = maxPlayers; EnablePortForwarding = enablePortForwarding; + Name = name; + ListPublicly = listPublicly; } public ServerConfig() @@ -32,6 +37,8 @@ public ServerConfig() Password = ""; MaxPlayers = 0; EnablePortForwarding = true; + Name = ""; + ListPublicly = false; } /// @@ -59,5 +66,15 @@ public ServerConfig() /// Default is false for security reasons. /// public bool EnablePortForwarding; + + /// + /// Gets the display name of the server, shown in the public server list. + /// + public string Name; + + /// + /// Gets whether this server should be listed in the public server list. + /// + public bool ListPublicly; } } diff --git a/src/csm/Networking/Server.cs b/src/csm/Networking/Server.cs index f7130ceb..b4738dde 100644 --- a/src/csm/Networking/Server.cs +++ b/src/csm/Networking/Server.cs @@ -31,8 +31,12 @@ public class Server // The server private readonly LiteNetLib.NetManager _netServer; - // Keep alive tick tracker - private int _keepAlive = 1; + // Keep alive timer + private DateTime _lastKeepAlive = DateTime.MinValue; + + // Must stay comfortably below the GS's KickTime (default 15s), since the + // registration entry expires if no heartbeat arrives before then. + private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(5); // Connected clients public Dictionary ConnectedPlayers { get; } = new Dictionary(); @@ -175,16 +179,30 @@ private void SetupHolePunching() _netServer.NatPunchModule.Init(natPunchListener); // Register on server + SendToApiServer(BuildRegistrationCommand()); + } + + /// + /// Builds a registration command containing the current connection and + /// public-listing information for this server. + /// + private ServerRegistrationCommand BuildRegistrationCommand() + { string localIp = NetUtils.GetLocalIp(LocalAddrType.IPv4); if (string.IsNullOrEmpty(localIp)) localIp = NetUtils.GetLocalIp(LocalAddrType.IPv6); - SendToApiServer(new ServerRegistrationCommand + return new ServerRegistrationCommand { LocalIp = localIp, LocalPort = Config.Port, - Token = ServerToken - }); + Token = ServerToken, + ServerName = Config.Name, + MaxPlayers = Config.MaxPlayers, + CurrentPlayers = ConnectedPlayers.Count, + HasPassword = !string.IsNullOrEmpty(Config.Password), + ListPublicly = Config.ListPublicly + }; } /// @@ -246,20 +264,11 @@ public void ProcessEvents() _netServer.NatPunchModule.PollEvents(); _netServer.PollEvents(); // Send keepalive to GS - if (_keepAlive % (60 * 5) == 0) + if (DateTime.Now.Subtract(_lastKeepAlive) >= KeepAliveInterval) { - string localIp = NetUtils.GetLocalIp(LocalAddrType.IPv4); - if (string.IsNullOrEmpty(localIp)) - localIp = NetUtils.GetLocalIp(LocalAddrType.IPv6); - - SendToApiServer(new ServerRegistrationCommand - { - LocalIp = localIp, - LocalPort = Config.Port, - Token = ServerToken - }); + SendToApiServer(BuildRegistrationCommand()); + _lastKeepAlive = DateTime.Now; } - _keepAlive += 1; } /// diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs new file mode 100644 index 00000000..ec6afb3d --- /dev/null +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Net; +using ColossalFramework.PlatformServices; +using ColossalFramework.UI; +using CSM.API; +using CSM.GS.Commands; +using CSM.GS.Commands.Data.ApiServer; +using CSM.Helpers; +using CSM.Networking; +using CSM.Networking.Config; +using LiteNetLib; +using UnityEngine; + +namespace CSM.Panels +{ + public class BrowseServersPanel : UIPanel + { + private static readonly TimeSpan RequestInterval = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ResponseTimeout = TimeSpan.FromSeconds(5); + + private UIScrollablePanel _scrollablePanel; + private UILabel _statusLabel; + private UIButton _refreshButton; + private UIButton _closeButton; + + private readonly List _rowLabels = new List(); + private readonly List _joinButtons = new List(); + + private LiteNetLib.NetManager _netManager; + private DateTime _lastRequestSent = DateTime.MinValue; + private bool _awaitingResponse; + + public override void Start() + { + AddUIComponent(typeof(UIDragHandle)); + + backgroundSprite = "GenericPanel"; + color = new Color32(110, 110, 110, 255); + + width = 450; + height = 500; + relativePosition = PanelManager.GetCenterPosition(this); + + this.CreateTitleLabel("Public Servers", new Vector2(140, -20)); + + _statusLabel = this.CreateLabel("Loading...", new Vector2(10, -60)); + _statusLabel.textAlignment = UIHorizontalAlignment.Center; + _statusLabel.width = 430; + + _scrollablePanel = AddUIComponent(); + _scrollablePanel.width = 410; + _scrollablePanel.height = 330; + _scrollablePanel.position = new Vector2(10, -85); + _scrollablePanel.clipChildren = true; + _scrollablePanel.name = "BrowseServersScrollablePanel"; + + this.AddScrollbar(_scrollablePanel); + + _refreshButton = this.CreateButton("Refresh", new Vector2(10, -430), 200); + _refreshButton.eventClick += (component, param) => SendServerListRequest(); + + _closeButton = this.CreateButton("Close", new Vector2(220, -430), 200); + _closeButton.eventClick += (component, param) => + { + isVisible = false; + }; + + SetupNetworking(); + } + + public override void Update() + { + if (!isVisible) + return; + _netManager?.PollEvents(); + + if (DateTime.Now.Subtract(_lastRequestSent) >= RequestInterval) + { + SendServerListRequest(); + } + else if (_awaitingResponse && DateTime.Now.Subtract(_lastRequestSent) >= ResponseTimeout) + { + _awaitingResponse = false; + _statusLabel.text = "Failed to reach the API server.\nCheck your CSM API Server settings."; + _statusLabel.isVisible = true; + } + } + + public override void OnDestroy() + { + _netManager?.Stop(); + base.OnDestroy(); + } + + private void SetupNetworking() + { + EventBasedNetListener listener = new EventBasedNetListener(); + listener.NetworkReceiveUnconnectedEvent += OnNetworkReceiveUnconnectedEvent; + + _netManager = new LiteNetLib.NetManager(listener) { UnconnectedMessagesEnabled = true }; + _netManager.Start(); + } + + private void OnNetworkReceiveUnconnectedEvent(IPEndPoint from, NetPacketReader reader, UnconnectedMessageType type) + { + if (type != UnconnectedMessageType.BasicMessage) + return; + + try + { + // Only allow responses from the API server + if (!Equals(from.Address, IpAddress.GetIpv4(CSM.Settings.ApiServer))) + return; + + CommandReceiver.Parse(reader); + } + catch (Exception ex) + { + Log.Warn($"Encountered an error while reading public server list response: {ex}"); + } + } + + private void SendServerListRequest() + { + try + { + IPAddress apiServer = IpAddress.GetIpv4(CSM.Settings.ApiServer); + byte[] data = ApiCommand.Serialize(new ServerListRequestCommand()); + _netManager.SendUnconnectedMessage(data, new IPEndPoint(apiServer, CSM.Settings.ApiServerPort)); + + _lastRequestSent = DateTime.Now; + _awaitingResponse = true; + + if (_rowLabels.Count == 0 && _joinButtons.Count == 0) + { + _statusLabel.text = "Loading..."; + _statusLabel.isVisible = true; + } + } + catch (Exception e) + { + Log.Warn($"Failed to send public server list request: {e.Message}"); + } + } + + /// + /// Called by ServerListResultHandler when a response arrives. + /// + public void OnServerListReceived(PublicServerEntry[] servers) + { + _awaitingResponse = false; + UpdateRows(servers); + } + + private void UpdateRows(PublicServerEntry[] servers) + { + foreach (UILabel label in _rowLabels) + { + Destroy(label); + } + + foreach (UIButton button in _joinButtons) + { + Destroy(button); + } + + _rowLabels.Clear(); + _joinButtons.Clear(); + + if (servers.Length == 0) + { + _statusLabel.text = "No public servers found."; + _statusLabel.isVisible = true; + return; + } + + _statusLabel.isVisible = false; + + int rowOffset = 0; + const int rowHeight = 40; + + foreach (PublicServerEntry server in servers) + { + string lockIcon = server.HasPassword ? " [Protected]" : ""; + string name = string.IsNullOrEmpty(server.Name) ? "Unnamed Server" : server.Name; + string label = $"{name}{lockIcon}\n{server.CurrentPlayers}/{server.MaxPlayers} players"; + + UILabel serverLabel = _scrollablePanel.CreateLabel(label, new Vector2(5, rowOffset), 260, rowHeight); + serverLabel.textScale = 0.8f; + serverLabel.wordWrap = true; + _rowLabels.Add(serverLabel); + + UIButton joinButton = _scrollablePanel.CreateButton("Join", new Vector2(280, rowOffset), 100, rowHeight); + PublicServerEntry capturedServer = server; + joinButton.eventClick += (component, param) => OnJoinClick(capturedServer); + _joinButtons.Add(joinButton); + + rowOffset -= rowHeight; + } + } + + private void OnJoinClick(PublicServerEntry server) + { + if (string.IsNullOrEmpty(server.ServerToken)) + { + Log.Warn($"Public server list entry for '{server.Name}' has no token, cannot join."); + return; + } + + isVisible = false; + + string username = GetSavedOrDefaultUsername(); + + if (server.HasPassword) + { + PanelManager.ShowPanel().Show(server.ServerToken, username); + } + else + { + JoinGamePanel.JoinByToken(server.ServerToken, username); + } + } + + private static string GetSavedOrDefaultUsername() + { + ClientConfig savedConfig = null; + ConfigData.Load(ref savedConfig, ConfigData.ClientFile); + + if (!string.IsNullOrEmpty(savedConfig?.Username)) + { + return savedConfig.Username; + } + + return PlatformService.active && PlatformService.personaName != null + ? PlatformService.personaName + : "Player"; + } + } +} diff --git a/src/csm/Panels/HostGamePanel.cs b/src/csm/Panels/HostGamePanel.cs index 7b7dabc9..07027599 100644 --- a/src/csm/Panels/HostGamePanel.cs +++ b/src/csm/Panels/HostGamePanel.cs @@ -18,6 +18,8 @@ public class HostGamePanel : UIPanel private UITextField _portField; private UITextField _passwordField; private UITextField _usernameField; + private UITextField _serverNameField; + private UITextField _maxPlayersField; private UILabel _connectionStatus; private UILabel _localIp; @@ -30,6 +32,7 @@ public class HostGamePanel : UIPanel private UICheckBox _passwordBox; private UICheckBox _rememberBox; private UICheckBox _portForwardingBox; + private UICheckBox _listPubliclyBox; private ServerConfig _serverConfig; private bool _hasRemembered; @@ -45,59 +48,75 @@ public override void Start() color = new Color32(110, 110, 110, 250); width = 360; - height = 625; + height = 810; relativePosition = PanelManager.GetCenterPosition(this); // Title Label this.CreateTitleLabel("Host Server", new Vector2(120, -20)); + // Server Name label + this.CreateLabel("Server Name (Optional):", new Vector2(10, -65)); + // Server Name field + _serverNameField = this.CreateTextField(_serverConfig.Name, new Vector2(10, -90)); + // Port Label - this.CreateLabel("Port:", new Vector2(10, -65)); + this.CreateLabel("Port:", new Vector2(10, -145)); // Port field - _portField = this.CreateTextField(_serverConfig.Port.ToString(), new Vector2(10, -90)); + _portField = this.CreateTextField(_serverConfig.Port.ToString(), new Vector2(10, -170)); _portField.numericalOnly = true; + // Max Players label + this.CreateLabel("Max Players (0 = No Limit):", new Vector2(10, -225)); + // Max Players field + _maxPlayersField = this.CreateTextField(_serverConfig.MaxPlayers.ToString(), new Vector2(10, -250)); + _maxPlayersField.numericalOnly = true; + // Password label - this.CreateLabel("Password (Optional):", new Vector2(10, -145)); + this.CreateLabel("Password (Optional):", new Vector2(10, -305)); // Password checkbox - _passwordBox = this.CreateCheckBox("Show Password", new Vector2(10, -170)); + _passwordBox = this.CreateCheckBox("Show Password", new Vector2(10, -330)); _passwordBox.isChecked = false; // Password field - _passwordField = this.CreateTextField(_serverConfig.Password, new Vector2(10, -190)); + _passwordField = this.CreateTextField(_serverConfig.Password, new Vector2(10, -350)); _passwordField.isPasswordField = true; // Username label - this.CreateLabel("Username:", new Vector2(10, -245)); + this.CreateLabel("Username:", new Vector2(10, -405)); // Username field - _usernameField = this.CreateTextField(_serverConfig.Username, new Vector2(10, -270)); + _usernameField = this.CreateTextField(_serverConfig.Username, new Vector2(10, -430)); if (PlatformService.active && PlatformService.personaName != null && _serverConfig.Username == "") { _usernameField.text = PlatformService.personaName; } // Remember-Me box - _rememberBox = this.CreateCheckBox("Remember Me", new Vector2(10, -325)); + _rememberBox = this.CreateCheckBox("Remember Me", new Vector2(10, -485)); _rememberBox.isChecked = _hasRemembered; // Port Forwarding box (UPnP) - _portForwardingBox = this.CreateCheckBox("Enable Port Forwarding (UPnP)", new Vector2(10, -350)); + _portForwardingBox = this.CreateCheckBox("Enable Port Forwarding (UPnP)", new Vector2(10, -510)); _portForwardingBox.isChecked = _serverConfig.EnablePortForwarding; _portForwardingBox.tooltip = "Automatically forward ports via UPnP."; - _connectionStatus = this.CreateLabel("", new Vector2(10, -375)); + // List Publicly box + _listPubliclyBox = this.CreateCheckBox("List this server publicly", new Vector2(10, -535)); + _listPubliclyBox.isChecked = _serverConfig.ListPublicly; + _listPubliclyBox.tooltip = "Allow other players to discover this server in the public server list."; + + _connectionStatus = this.CreateLabel("", new Vector2(10, -560)); _connectionStatus.textAlignment = UIHorizontalAlignment.Center; _connectionStatus.textColor = new Color32(255, 0, 0, 255); // Create Local IP Label - _localIp = this.CreateLabel("", new Vector2(10, -405)); + _localIp = this.CreateLabel("", new Vector2(10, -590)); _localIp.textAlignment = UIHorizontalAlignment.Center; // Create External IP Label - _externalIp = this.CreateLabel("", new Vector2(10, -425)); + _externalIp = this.CreateLabel("", new Vector2(10, -610)); _externalIp.textAlignment = UIHorizontalAlignment.Center; // Create VPN IP Label - _vpnIp = this.CreateLabel("", new Vector2(10, -445)); + _vpnIp = this.CreateLabel("", new Vector2(10, -630)); _vpnIp.textAlignment = UIHorizontalAlignment.Center; // Request IP addresses async @@ -111,11 +130,11 @@ public override void Start() }; // Create Server Button - _createButton = this.CreateButton("Create Server", new Vector2(10, -490)); + _createButton = this.CreateButton("Create Server", new Vector2(10, -675)); _createButton.eventClick += OnCreateServerClick; // Close this dialog - _closeButton = this.CreateButton("Cancel", new Vector2(10, -560)); + _closeButton = this.CreateButton("Cancel", new Vector2(10, -745)); _closeButton.eventClick += (component, param) => { isVisible = false; @@ -171,7 +190,9 @@ private void RequestIPs() /// private void OnCreateServerClick(UIComponent uiComponent, UIMouseEventParameter eventParam) { - _serverConfig = new ServerConfig(Int32.Parse(_portField.text), _usernameField.text, _passwordField.text, 0, _portForwardingBox.isChecked); + int.TryParse(_maxPlayersField.text, out int maxPlayers); + _serverConfig = new ServerConfig(Int32.Parse(_portField.text), _usernameField.text, _passwordField.text, maxPlayers, _portForwardingBox.isChecked, + _serverNameField.text, _listPubliclyBox.isChecked); ConfigData.Save(ref _serverConfig, ConfigData.ServerFile, _rememberBox.isChecked); _connectionStatus.textColor = new Color32(255, 255, 0, 255); @@ -193,6 +214,14 @@ private void OnCreateServerClick(UIComponent uiComponent, UIMouseEventParameter return; } + // Max players must be a non-negative number + if (!string.IsNullOrEmpty(_maxPlayersField.text) && (!int.TryParse(_maxPlayersField.text, out int _) || maxPlayers < 0)) + { + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = "Max players must be a non-negative number"; + return; + } + // Username must be filled in if (string.IsNullOrEmpty(_usernameField.text)) { diff --git a/src/csm/Panels/JoinGamePanel.cs b/src/csm/Panels/JoinGamePanel.cs index 5df5a091..00804217 100644 --- a/src/csm/Panels/JoinGamePanel.cs +++ b/src/csm/Panels/JoinGamePanel.cs @@ -2,6 +2,7 @@ using ColossalFramework.PlatformServices; using ColossalFramework.Threading; using ColossalFramework.UI; +using CSM.API; using CSM.API.Commands; using CSM.Helpers; using CSM.Mods; @@ -23,6 +24,7 @@ public class JoinGamePanel : UIPanel private UIButton _connectButton; private UIButton _closeButton; private UIButton _troubleshootingButton; + private UIButton _browseServersButton; private UICheckBox _passwordBox; private UICheckBox _rememberBox; @@ -46,7 +48,7 @@ public override void Start() color = new Color32(110, 110, 110, 255); width = 360; - height = 585; + height = 655; relativePosition = PanelManager.GetCenterPosition(this); // Title Label @@ -106,6 +108,14 @@ public override void Start() MultiplayerManager.Instance.CurrentClient.StopMainMenuEventProcessor(); }; + // Browse public servers + _browseServersButton = this.CreateButton("Browse Public Servers", new Vector2(10, -585)); + _browseServersButton.eventClick += (component, param) => + { + isVisible = false; + PanelManager.ShowPanel(); + }; + _connectionStatus = this.CreateLabel("", new Vector2(10, -420)); _connectionStatus.textAlignment = UIHorizontalAlignment.Center; _connectionStatus.textColor = new Color32(255, 0, 0, 255); @@ -246,6 +256,57 @@ void Action() } } + /// + /// Attempts to join a server by its NAT-relay token (rather than a + /// direct IP/port). Shared by the Steam friend-invite join flow and + /// the public server browser join flow, so both behave consistently: + /// on success, hide this panel and load into the game; on failure, + /// show a password prompt if that's the failure reason, otherwise + /// fall back to this panel's manual form with the error shown. + /// + public static void JoinByToken(string token, string username, string password = null) + { + Log.Info("Join request for " + token); + + JoinGamePanel join = PanelManager.ShowPanel(); + join.SetConnecting(); + + MultiplayerManager.Instance.CurrentClient.StartMainMenuEventProcessor(); + + ClientConfig clientConfig = password != null + ? new ClientConfig(token, username, password) + : new ClientConfig(token, username); + + MultiplayerManager.Instance.ConnectToServer(clientConfig, success => + { + if (success) + { + ThreadHelper.dispatcher.Dispatch(() => + { + MultiplayerManager.Instance.BlockGameFirstJoin(); + PanelManager.HidePanel(); + }); + return; + } + + string reason = MultiplayerManager.Instance.CurrentClient.ConnectionMessage; + + ThreadHelper.dispatcher.Dispatch(() => + { + if (reason != null && reason.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0) + { + PanelManager.HidePanel(); + PanelManager.ShowPanel().Show(token, username, reason); + } + else + { + JoinGamePanel panel = PanelManager.ShowPanel(); + panel.FillFieldsOnError(token, username, reason); + } + }); + }); + } + public void SetConnecting() { void Action() diff --git a/src/csm/Panels/PasswordPromptPanel.cs b/src/csm/Panels/PasswordPromptPanel.cs new file mode 100644 index 00000000..08c7f40d --- /dev/null +++ b/src/csm/Panels/PasswordPromptPanel.cs @@ -0,0 +1,121 @@ +using ColossalFramework.UI; +using CSM.API.Commands; +using CSM.Helpers; +using CSM.Networking; +using UnityEngine; + +namespace CSM.Panels +{ + /// + /// A focused password-entry prompt used when joining a password-protected + /// server (from the public server browser, or a retry after JoinByToken + /// failed with a password-related error). Deliberately doesn't expose the + /// target token/address in the UI - a browsed server's connection details + /// weren't something the player entered themselves, so there's no need to + /// display them. Delegates the actual connection attempt to + /// JoinGamePanel.JoinByToken. + /// + public class PasswordPromptPanel : UIPanel + { + private UITextField _usernameField; + private UITextField _passwordField; + private UILabel _connectionStatus; + private UIButton _connectButton; + private UIButton _cancelButton; + + private string _targetToken; + + public override void Start() + { + AddUIComponent(typeof(UIDragHandle)); + + backgroundSprite = "GenericPanel"; + color = new Color32(110, 110, 110, 255); + + width = 360; + height = 335; + relativePosition = PanelManager.GetCenterPosition(this); + + this.CreateTitleLabel("Password Required", new Vector2(80, -20)); + + this.CreateLabel("This server requires a password to join.", new Vector2(10, -60), 340); + + this.CreateLabel("Username:", new Vector2(10, -95)); + _usernameField = this.CreateTextField("", new Vector2(10, -120)); + + this.CreateLabel("Password:", new Vector2(10, -160)); + _passwordField = this.CreateTextField("", new Vector2(10, -185)); + _passwordField.isPasswordField = true; + + _connectionStatus = this.CreateLabel("", new Vector2(10, -225)); + _connectionStatus.textAlignment = UIHorizontalAlignment.Center; + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + + _connectButton = this.CreateButton("Connect", new Vector2(10, -265), 165); + _connectButton.eventClick += OnConnectClick; + + _cancelButton = this.CreateButton("Cancel", new Vector2(185, -265), 165); + _cancelButton.eventClick += (component, param) => + { + isVisible = false; + }; + } + + public override void Update() + { + if (isVisible && Input.GetKeyDown(KeyCode.Escape)) + { + isVisible = false; + } + } + + /// + /// Shows the prompt for the given target server token. The token is + /// kept only in memory here, never rendered in the UI. + /// + /// The server's NAT-relay token. + /// The username to pre-fill (editable). + /// + /// An optional error to display, e.g. when re-showing this prompt + /// after a wrong password was entered. + /// + public void Show(string token, string username, string errorMessage = null) + { + _targetToken = token; + _usernameField.text = username; + _passwordField.text = ""; + + if (!string.IsNullOrEmpty(errorMessage)) + { + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = errorMessage; + } + else + { + _connectionStatus.text = ""; + } + + isVisible = true; + } + + private void OnConnectClick(UIComponent component, UIMouseEventParameter param) + { + if (MultiplayerManager.Instance.CurrentRole == MultiplayerRole.Server) + { + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = "Already Running Server"; + return; + } + + if (string.IsNullOrEmpty(_usernameField.text)) + { + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = "Invalid Username"; + return; + } + + isVisible = false; + JoinGamePanel.JoinByToken(_targetToken, _usernameField.text, _passwordField.text); + } + } +} diff --git a/src/csm/Panels/SettingsPanel.cs b/src/csm/Panels/SettingsPanel.cs index 437adbdd..da9cfc4f 100644 --- a/src/csm/Panels/SettingsPanel.cs +++ b/src/csm/Panels/SettingsPanel.cs @@ -51,7 +51,7 @@ public static void Build(UIHelperBase helper, Settings settings) { try { - new CSMWebClient().DownloadString($"http://{url}/api/version"); + new CSMWebClient().DownloadString($"http://{url}:{settings.ApiServerHttpPort.value}/api/version"); settings.ApiServer.value = url; } catch (Exception) @@ -76,6 +76,12 @@ public static void Build(UIHelperBase helper, Settings settings) }); portInput.numericalOnly = true; + UITextField httpPortInput = (UITextField) advancedGroup.AddTextfield("API Server HTTP Port", settings.ApiServerHttpPort.value.ToString(), text => {}, port => + { + settings.ApiServerHttpPort.value = int.Parse(port); + }); + httpPortInput.numericalOnly = true; + UIHelperBase buttonsGroup = helper.AddGroup("Buttons"); buttonsGroup.AddButton("Show Release Notes", () => { diff --git a/src/csm/Settings.cs b/src/csm/Settings.cs index 74670bdb..77f2653e 100644 --- a/src/csm/Settings.cs +++ b/src/csm/Settings.cs @@ -19,6 +19,7 @@ public Settings() private const string DefaultLastSeenReleaseNotes = "0.0"; private const string DefaultApiServer = "api.citiesskylinesmultiplayer.com"; private const int DefaultApiServerPort = 4240; + private const int DefaultApiServerHttpPort = 80; public readonly SavedBool DebugLogging = new SavedBool(nameof(DebugLogging), SettingsFile, DefaultDebugLogging, true); @@ -46,5 +47,13 @@ public bool UseChirper public readonly SavedInt ApiServerPort = new SavedInt(nameof(ApiServerPort), SettingsFile, DefaultApiServerPort, true); + + /// + /// The port that the API server's HTTP endpoint runs on. Only used for the + /// update/version check (/api/version) - the public server list is served + /// over UDP on ApiServerPort, not HTTP. + /// + public readonly SavedInt ApiServerHttpPort = + new SavedInt(nameof(ApiServerHttpPort), SettingsFile, DefaultApiServerHttpPort, true); } } diff --git a/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs b/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs new file mode 100644 index 00000000..2f63f11d --- /dev/null +++ b/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs @@ -0,0 +1,32 @@ +using ProtoBuf; + +namespace CSM.GS.Commands.Data.ApiServer +{ + /// + /// A single entry in the public server list. Only contains information + /// safe to expose publicly (no internal/external IP distinction - joining + /// goes through the NAT-relay token, same as a Steam friend-invite join). + /// + [ProtoContract] + public class PublicServerEntry + { + [ProtoMember(1)] + public string Name { get; set; } + + [ProtoMember(2)] + public int CurrentPlayers { get; set; } + + [ProtoMember(3)] + public int MaxPlayers { get; set; } + + [ProtoMember(4)] + public bool HasPassword { get; set; } + + /// + /// The server's NAT-relay token, used to join via JoinGamePanel.JoinByToken - + /// the same mechanism used for Steam friend-invite joins. + /// + [ProtoMember(6)] + public string ServerToken { get; set; } + } +} diff --git a/src/gs/Commands/Data/ApiServer/ServerListRequestCommand.cs b/src/gs/Commands/Data/ApiServer/ServerListRequestCommand.cs new file mode 100644 index 00000000..35716afa --- /dev/null +++ b/src/gs/Commands/Data/ApiServer/ServerListRequestCommand.cs @@ -0,0 +1,14 @@ +using ProtoBuf; + +namespace CSM.GS.Commands.Data.ApiServer +{ + /// + /// Requests the current list of publicly-listed servers from the API server. + /// + /// Sent by: + /// - Mod (browsing client) + [ProtoContract] + public class ServerListRequestCommand : ApiCommandBase + { + } +} diff --git a/src/gs/Commands/Data/ApiServer/ServerListResultCommand.cs b/src/gs/Commands/Data/ApiServer/ServerListResultCommand.cs new file mode 100644 index 00000000..a76da41e --- /dev/null +++ b/src/gs/Commands/Data/ApiServer/ServerListResultCommand.cs @@ -0,0 +1,17 @@ +using ProtoBuf; + +namespace CSM.GS.Commands.Data.ApiServer +{ + /// + /// The response to a ServerListRequestCommand, containing the current + /// list of publicly-listed servers. + /// + /// Sent by: + /// - API server + [ProtoContract] + public class ServerListResultCommand : ApiCommandBase + { + [ProtoMember(1)] + public PublicServerEntry[] Servers { get; set; } + } +} diff --git a/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs b/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs index 69a2f342..ea7f8d86 100644 --- a/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs +++ b/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs @@ -27,5 +27,35 @@ public class ServerRegistrationCommand : ApiCommandBase /// [ProtoMember(3)] public int LocalPort { get; set; } + + /// + /// The display name of the server, shown in the public server list. + /// + [ProtoMember(4)] + public string ServerName { get; set; } + + /// + /// The maximum amount of players that can connect to this server. + /// + [ProtoMember(5)] + public int MaxPlayers { get; set; } + + /// + /// The current amount of connected players. + /// + [ProtoMember(6)] + public int CurrentPlayers { get; set; } + + /// + /// Whether a password is required to join this server. + /// + [ProtoMember(7)] + public bool HasPassword { get; set; } + + /// + /// Whether this server should be listed in the public server list. + /// + [ProtoMember(8)] + public bool ListPublicly { get; set; } } } diff --git a/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs b/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs new file mode 100644 index 00000000..af30e169 --- /dev/null +++ b/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs @@ -0,0 +1,22 @@ +using System.Linq; +using CSM.GS.Commands.Data.ApiServer; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListRequestHandler : ApiCommandHandler + { + protected override void Handle(ServerListRequestCommand command) + { + PublicServerEntry[] servers = worker.PublicServers.Select(server => new PublicServerEntry + { + Name = server.ServerName, + CurrentPlayers = server.CurrentPlayers, + MaxPlayers = server.MaxPlayers, + HasPassword = server.HasPassword, + ServerToken = server.Token + }).ToArray(); + + worker.SendToServer(sender, new ServerListResultCommand { Servers = servers }); + } + } +} diff --git a/src/gs/Commands/Handler/ApiServer/ServerListResultHandler.cs b/src/gs/Commands/Handler/ApiServer/ServerListResultHandler.cs new file mode 100644 index 00000000..636dfbd5 --- /dev/null +++ b/src/gs/Commands/Handler/ApiServer/ServerListResultHandler.cs @@ -0,0 +1,12 @@ +using CSM.GS.Commands.Data.ApiServer; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListResultHandler : ApiCommandHandler + { + protected override void Handle(ServerListResultCommand command) + { + // Do nothing, this is a packet for the browsing client + } + } +} diff --git a/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs b/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs index f5915d36..9285fc2e 100644 --- a/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs +++ b/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs @@ -7,7 +7,8 @@ public class ServerRegistrationHandler : ApiCommandHandler public DateTime LastPing { get; } - public Server(IPEndPoint internalAddress, IPEndPoint externalAddress, string token) + /// + /// The display name of the server, shown in the public server list. + /// + public string ServerName { get; } + + /// + /// The maximum amount of players that can connect to this server. + /// + public int MaxPlayers { get; } + + /// + /// The current amount of connected players. + /// + public int CurrentPlayers { get; } + + /// + /// Whether a password is required to join this server. + /// + public bool HasPassword { get; } + + /// + /// Whether this server should be listed in the public server list. + /// + public bool ListPublicly { get; } + + public Server(IPEndPoint internalAddress, IPEndPoint externalAddress, string token, + string serverName = null, int maxPlayers = 0, int currentPlayers = 0, + bool hasPassword = false, bool listPublicly = false) { LastPing = DateTime.Now; Token = token; InternalAddress = internalAddress; ExternalAddress = externalAddress; + ServerName = serverName; + MaxPlayers = maxPlayers; + CurrentPlayers = currentPlayers; + HasPassword = hasPassword; + ListPublicly = listPublicly; } } } diff --git a/src/gs/WorkerService.cs b/src/gs/WorkerService.cs index 7025bf14..26f40987 100644 --- a/src/gs/WorkerService.cs +++ b/src/gs/WorkerService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Reflection; using System.Threading; @@ -36,6 +37,18 @@ public class WorkerService : BackgroundService, INatPunchListener private readonly List _serversToRemove = new(); private readonly List _serversToRemoveByToken = new(); + /// + /// Servers that have opted in to being listed publicly. Keyed by token. + /// Read concurrently by the public server list HTTP endpoint, so this must + /// stay a thread-safe collection. + /// + private readonly ConcurrentDictionary _publicServers = new(); + + /// + /// Read-only snapshot of the currently publicly-listed, non-expired servers. + /// + public IReadOnlyCollection PublicServers => _publicServers.Values.ToArray(); + private readonly ConcurrentQueue _tasks = new(); private readonly Type _responsePacketType; @@ -127,6 +140,7 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) foreach (string token in _serversToRemoveByToken) { _gameServersByToken.Remove(token); + _publicServers.TryRemove(token, out _); } _serversToRemove.Clear(); @@ -206,16 +220,27 @@ public void OnNatIntroductionSuccess(IPEndPoint targetEndPoint, NatAddressType t // Ignore as we are the API server } - public void RegisterServer(IPEndPoint remoteEndPoint, IPEndPoint localEndPoint, string token) + public void RegisterServer(IPEndPoint remoteEndPoint, IPEndPoint localEndPoint, string token, + string serverName = null, int maxPlayers = 0, int currentPlayers = 0, + bool hasPassword = false, bool listPublicly = false) { if (!_gameServers.ContainsKey(remoteEndPoint.Address)) { _logger.LogInformation("[{ExternalAddress}] Registered Server: Internal Address={InternalAddress} Token={Token}", Anonymize(remoteEndPoint), Anonymize(localEndPoint), token); } // Always create new server entry, so that port numbers and the token are updated - Server server = new(localEndPoint, remoteEndPoint, token); + Server server = new(localEndPoint, remoteEndPoint, token, serverName, maxPlayers, currentPlayers, hasPassword, listPublicly); _gameServers[remoteEndPoint.Address] = server; _gameServersByToken[token] = server; + + if (listPublicly) + { + _publicServers[token] = server; + } + else + { + _publicServers.TryRemove(token, out _); + } } public void SendToServer(IPEndPoint server, ApiCommandBase command)