using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; namespace CommonwealthOnline.Server; internal static class Program { public static async Task Main(string[] args) { try { return await RunAsync(args).ConfigureAwait(false); } catch (Exception ex) { Console.Error.WriteLine($"[ERROR] {ex.Message}"); return 2; } } private static async Task RunAsync(string[] args) { if (args.Length == 0 || args[0] is "help" or "--help" or "-h") { PrintHelp(); return 0; } var command = args[0].ToLowerInvariant(); return command switch { "serve" => await ServeAsync(args[1..]).ConfigureAwait(false), "status" => await AdminCommandAsync(new JsonObject { ["cmd"] = "status" }, args[1..]).ConfigureAwait(false), "clients" or "users" => await AdminCommandAsync(new JsonObject { ["cmd"] = "clients" }, args[1..]).ConfigureAwait(false), "bans" => await AdminCommandAsync(new JsonObject { ["cmd"] = "bans" }, args[1..]).ConfigureAwait(false), "kick" => await KickAsync(args[1..]).ConfigureAwait(false), "ban" => await BanAsync(args[1..]).ConfigureAwait(false), "unban" => await UnbanAsync(args[1..]).ConfigureAwait(false), "world" => await WorldAsync(args[1..]).ConfigureAwait(false), "config" => ConfigCommand(args[1..]), "load-test" => await LoadTestAsync(args[1..]).ConfigureAwait(false), _ => Unknown(command) }; } private static async Task ServeAsync(string[] args) { var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json"; var fullConfigPath = Path.GetFullPath(configPath); if (!File.Exists(fullConfigPath)) { Console.WriteLine($"Generating default configuration at {fullConfigPath}"); ServerOptions.CreateDefault(fullConfigPath); } var options = ServerOptions.Load(fullConfigPath); if (GetOption(args, "--host", "-H") is { } host) options.Host = host; if (GetOption(args, "--port", "-p") is { } portText) { if (!int.TryParse(portText, out var port)) throw new ArgumentException("--port requires an integer."); options.Port = port; } var errors = options.Validate(); if (errors.Count > 0) { foreach (var error in errors) Console.Error.WriteLine($"[ERROR] {error}"); return 2; } await using var runtime = new ServerRuntime(options); runtime.Server.LogMessage += (message, level) => { var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); Console.WriteLine($"{timestamp} {level.ToUpperInvariant()} {message}"); }; runtime.Start(); PrintStartup(options); using var shutdown = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; shutdown.Cancel(); }; PosixSignalRegistration? sigterm = null; if (!OperatingSystem.IsWindows()) { sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; shutdown.Cancel(); }); } try { if (HasFlag(args, "--interactive", "-i") && !Console.IsInputRedirected) await RunInteractiveAsync(options, shutdown).ConfigureAwait(false); else await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token).ConfigureAwait(false); } catch (OperationCanceledException) { } finally { sigterm?.Dispose(); } return 0; } private static async Task RunInteractiveAsync(ServerOptions options, CancellationTokenSource shutdown) { Console.WriteLine("Type help for commands. Type quit to stop."); while (!shutdown.IsCancellationRequested) { Console.Write("commonwealth> "); var line = Console.ReadLine(); if (line is null) break; var parts = SplitCommandLine(line); if (parts.Length == 0) continue; if (parts[0] is "quit" or "exit" or "stop") { shutdown.Cancel(); break; } if (parts[0] == "help") { PrintInteractiveHelp(); continue; } var forwarded = parts[0] switch { "users" => new JsonObject { ["cmd"] = "clients" }, "clients" => new JsonObject { ["cmd"] = "clients" }, "status" => new JsonObject { ["cmd"] = "status" }, "bans" => new JsonObject { ["cmd"] = "bans" }, _ => null }; if (forwarded is not null) { await PrintAdminResponseAsync(forwarded, options).ConfigureAwait(false); continue; } if (parts[0] == "kick" && parts.Length >= 2 && uint.TryParse(parts[1], out var kickId)) { await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = kickId, ["reason"] = ReadReason(parts, 2) }, options).ConfigureAwait(false); continue; } if (parts[0] == "ban" && parts.Length >= 2) { var request = new JsonObject { ["cmd"] = "ban", ["reason"] = ReadReason(parts, 2) }; if (uint.TryParse(parts[1], out var banId)) request["playerId"] = banId; else request["ip"] = parts[1]; await PrintAdminResponseAsync(request, options).ConfigureAwait(false); continue; } if (parts[0] == "unban" && parts.Length >= 2) { await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = parts[1] }, options).ConfigureAwait(false); continue; } if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "time") { await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_time", ["hhmm"] = parts[2] }, options).ConfigureAwait(false); continue; } if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "weather") { await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_weather", ["weather"] = parts[2] }, options).ConfigureAwait(false); continue; } Console.WriteLine("Unknown command. Type help."); } } private static async Task AdminCommandAsync(JsonObject request, string[] args) { var options = LoadOptionsForAdmin(args); return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; } private static async Task KickAsync(string[] args) { if (args.Length == 0 || !uint.TryParse(args[0], out var id)) throw new ArgumentException("kick requires PLAYER_ID"); var options = LoadOptionsForAdmin(args[1..]); var request = new JsonObject { ["cmd"] = "kick", ["playerId"] = id, ["reason"] = GetOption(args, "--reason") ?? string.Empty }; return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; } private static async Task BanAsync(string[] args) { if (args.Length == 0) throw new ArgumentException("ban requires PLAYER_ID_OR_IP"); var request = new JsonObject { ["cmd"] = "ban", ["reason"] = GetOption(args, "--reason") ?? string.Empty }; if (uint.TryParse(args[0], out var id)) request["playerId"] = id; else request["ip"] = args[0]; var options = LoadOptionsForAdmin(args[1..]); return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; } private static async Task UnbanAsync(string[] args) { if (args.Length == 0) throw new ArgumentException("unban requires IP"); var options = LoadOptionsForAdmin(args[1..]); return await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = args[0] }, options).ConfigureAwait(false) ? 0 : 1; } private static async Task WorldAsync(string[] args) { if (args.Length < 2) throw new ArgumentException("world requires 'time HHmm' or 'weather FORM_ID'"); var request = args[0].ToLowerInvariant() switch { "time" => new JsonObject { ["cmd"] = "world_time", ["hhmm"] = args[1] }, "weather" => new JsonObject { ["cmd"] = "world_weather", ["weather"] = args[1] }, _ => throw new ArgumentException("world requires 'time' or 'weather'") }; var options = LoadOptionsForAdmin(args[2..]); return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; } private static int ConfigCommand(string[] args) { if (args.Length == 0 || args[0] != "init") throw new ArgumentException("config requires 'init [OUTPUT_PATH]'"); var path = Path.GetFullPath(args.Length > 1 ? args[1] : "commonwealth-server.json"); if (File.Exists(path)) throw new IOException($"Config already exists: {path}"); ServerOptions.CreateDefault(path); Console.WriteLine(path); return 0; } private static async Task LoadTestAsync(string[] args) { var host = GetOption(args, "--host", "-H") ?? "127.0.0.1"; var port = int.TryParse(GetOption(args, "--port", "-p"), out var parsedPort) ? parsedPort : 7777; var count = int.TryParse(GetOption(args, "--clients", "-n"), out var parsedCount) ? parsedCount : 16; if (count is < 1 or > 256) throw new ArgumentOutOfRangeException(nameof(count), "client count must be 1-256"); var clients = new List(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); try { for (var i = 0; i < count; i++) { var client = new SyntheticProtocolClient(); await client.ConnectAsync(host, port, timeout.Token).ConfigureAwait(false); clients.Add(client); var cell = (0x1000 + i).ToString("X8"); await client.SendTransformAsync(i * 1000.0, 0, 0, cell, string.Empty, "spawn", timeout.Token).ConfigureAwait(false); } var ids = clients.Select(x => x.PlayerId).ToArray(); if (ids.Distinct().Count() != ids.Length) throw new InvalidOperationException("Server assigned duplicate player IDs."); Console.WriteLine($"Connected {clients.Count} clients with unique server-owned IDs: {string.Join(", ", ids)}"); return 0; } finally { foreach (var client in clients) await client.DisposeAsync(); } } private static ServerOptions LoadOptionsForAdmin(string[] args) { var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json"; return ServerOptions.Load(Path.GetFullPath(configPath)); } private static async Task PrintAdminResponseAsync(JsonObject request, ServerOptions options) { JsonObject response; try { response = await AdminClient.SendAsync(request, options.AdminPort, options.AdminTokenPath).ConfigureAwait(false); } catch (Exception ex) { Console.Error.WriteLine($"Error talking to admin port 127.0.0.1:{options.AdminPort}: {ex.Message}"); return false; } Console.WriteLine(response.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); return JsonHelpers.Boolean(response["ok"]) == true; } private static void PrintStartup(ServerOptions options) { Console.WriteLine($"Commonwealth Online Server\nBind: {options.Host}:{options.Port}\nMax players: {options.MaxPlayers}\nLAN discovery: UDP {LanDiscoveryService.DiscoveryPort}\nAdmin: 127.0.0.1:{options.AdminPort}\nGNS: {(options.EnableGnsTransport ? "enabled" : "disabled")}\n"); } private static string? GetOption(string[] args, params string[] names) { for (var i = 0; i < args.Length; i++) { foreach (var name in names) { if (args[i] == name) { if (i + 1 >= args.Length) throw new ArgumentException($"{name} requires a value."); return args[i + 1]; } if (args[i].StartsWith(name + "=", StringComparison.Ordinal)) return args[i][(name.Length + 1)..]; } } return null; } private static bool HasFlag(string[] args, params string[] names) => args.Any(arg => names.Contains(arg, StringComparer.Ordinal)); private static string[] SplitCommandLine(string input) { var result = new List(); var current = new System.Text.StringBuilder(); var quoted = false; for (var i = 0; i < input.Length; i++) { var ch = input[i]; if (ch == '"') { quoted = !quoted; continue; } if (char.IsWhiteSpace(ch) && !quoted) { if (current.Length > 0) { result.Add(current.ToString()); current.Clear(); } continue; } current.Append(ch); } if (current.Length > 0) result.Add(current.ToString()); return result.ToArray(); } private static string ReadReason(string[] parts, int start) { var index = Array.IndexOf(parts, "--reason", start); return index >= 0 && index + 1 < parts.Length ? parts[index + 1] : string.Empty; } private static int Unknown(string command) { Console.Error.WriteLine($"Unknown command: {command}"); PrintHelp(); return 2; } private static void PrintHelp() { Console.WriteLine(""" Commonwealth Online Server Commands: serve [--config PATH] [--host HOST] [--port PORT] [--interactive] status [--config PATH] clients [--config PATH] users [--config PATH] kick PLAYER_ID [--reason TEXT] [--config PATH] ban PLAYER_ID_OR_IP [--reason TEXT] [--config PATH] unban IP [--config PATH] bans [--config PATH] world time HHmm [--config PATH] world weather FORM_ID [--config PATH] config init [OUTPUT_PATH] load-test [--host HOST] [--port PORT] [--clients N] """); } private static void PrintInteractiveHelp() { Console.WriteLine("help | status | users | kick ID [--reason TEXT] | ban ID_OR_IP [--reason TEXT] | unban IP | bans | world time HHmm | world weather FORM_ID | quit"); } }