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)