338 lines
14 KiB
C#
338 lines
14 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace CommonwealthOnline.Server;
|
|
|
|
internal static class Program
|
|
{
|
|
public static async Task<int> 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<int> 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<int> 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<int> AdminCommandAsync(JsonObject request, string[] args)
|
|
{
|
|
var options = LoadOptionsForAdmin(args);
|
|
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
|
|
}
|
|
|
|
private static async Task<int> 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<int> 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<int> 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<int> 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<int> 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<SyntheticProtocolClient>();
|
|
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<bool> 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<string>();
|
|
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");
|
|
}
|
|
}
|