diff --git a/.github/workflows/csharp-server.yml b/.github/workflows/csharp-server.yml new file mode 100644 index 0000000..e264962 --- /dev/null +++ b/.github/workflows/csharp-server.yml @@ -0,0 +1,39 @@ +name: CSharp Server Gate + +on: + push: + branches: + - rewrite/csharp-server + paths: + - "server/**/*.cs" + - "server/**/*.csproj" + - ".github/workflows/csharp-server.yml" + workflow_dispatch: + +jobs: + build-and-test: + runs-on: [self-hosted, Linux, X64] + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Report .NET + run: dotnet --info + + - name: Build C# server + run: dotnet build CommonwealthOnline.Server.csproj -c Release --nologo + + - name: Run C# server tests + run: dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release --no-restore + + - name: Publish Linux server + run: dotnet publish CommonwealthOnline.Server.csproj -c Release -r linux-x64 --self-contained false -o publish/linux-x64 --nologo + + - name: Verify published entrypoint + run: test -f publish/linux-x64/CommonwealthOnline.Server.dll diff --git a/server/AdminDiscovery.cs b/server/AdminDiscovery.cs new file mode 100644 index 0000000..1c42ee7 --- /dev/null +++ b/server/AdminDiscovery.cs @@ -0,0 +1,311 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal static class AdminTokenStore +{ + public static string LoadOrCreate(string path) + { + var full = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + if (File.Exists(full)) + { + var existing = File.ReadAllText(full, Encoding.UTF8).Trim(); + if (!string.IsNullOrEmpty(existing)) return existing; + } + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var temp = Path.Combine(Path.GetDirectoryName(full)!, $".{Path.GetFileName(full)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) + { + writer.WriteLine(token); + writer.Flush(); + stream.Flush(true); + } + BanStore.TryRestrictPermissions(temp); + File.Move(temp, full, true); + BanStore.TryRestrictPermissions(full); + return token; + } + catch + { + try { File.Delete(temp); } catch { } + if (File.Exists(full)) + { + var existing = File.ReadAllText(full, Encoding.UTF8).Trim(); + if (!string.IsNullOrEmpty(existing)) return existing; + } + throw; + } + } + + public static string Load(string path) + { + var token = File.ReadAllText(Path.GetFullPath(path), Encoding.UTF8).Trim(); + return string.IsNullOrEmpty(token) ? throw new InvalidOperationException($"Admin token file is empty: {path}") : token; + } + + public static bool EqualsConstantTime(string supplied, string expected) + { + var left = Encoding.UTF8.GetBytes(supplied); + var right = Encoding.UTF8.GetBytes(expected); + return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right); + } +} + +internal sealed class AdminControlServer : IAsyncDisposable +{ + private readonly AuthoritativeServer _server; + private readonly ServerOptions _options; + private readonly string _token; + private readonly CancellationTokenSource _shutdown = new(); + private readonly List _clientTasks = new(); + private readonly object _gate = new(); + private TcpListener? _listener; + private Task? _acceptTask; + + public AdminControlServer(AuthoritativeServer server, ServerOptions options) + { + _server = server; + _options = options; + _token = AdminTokenStore.LoadOrCreate(options.AdminTokenPath); + } + + public void Start() + { + _listener = new TcpListener(IPAddress.Loopback, _options.AdminPort); + _listener.Start(); + _acceptTask = Task.Run(() => AcceptLoopAsync(_shutdown.Token)); + _server.Log($"Admin control listening on 127.0.0.1:{_options.AdminPort} (localhost, authenticated)"); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener!.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { if (cancellationToken.IsCancellationRequested) break; else continue; } + var task = HandleClientAsync(client, cancellationToken); + lock (_gate) _clientTasks.Add(task); + _ = task.ContinueWith(_ => { lock (_gate) _clientTasks.Remove(task); }, TaskScheduler.Default); + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) + { + using (client) + { + var stream = client.GetStream(); + var read = new byte[4096]; + using var line = new MemoryStream(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + var count = await stream.ReadAsync(read, cancellationToken).ConfigureAwait(false); + if (count == 0) break; + for (var i = 0; i < count; i++) + { + if (read[i] == (byte)'\n') + { + var data = line.ToArray(); line.SetLength(0); + if (data.Length == 0) continue; + var response = data.Length > ProtocolConstants.MaxMessageBytes + ? Fail("Admin request is too large.") + : await DispatchAsync(data).ConfigureAwait(false); + var encoded = JsonSerializer.SerializeToUtf8Bytes(response); + await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + continue; + } + line.WriteByte(read[i]); + if (line.Length > ProtocolConstants.MaxMessageBytes) return; + } + } + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) { } + } + } + + private async Task DispatchAsync(byte[] data) + { + JsonObject request; + try { request = JsonNode.Parse(data) as JsonObject ?? throw new JsonException("request must be an object"); } + catch (JsonException ex) { return Fail($"Invalid JSON: {ex.Message}"); } + var supplied = JsonHelpers.String(request["adminToken"]); + request.Remove("adminToken"); + if (supplied is null || !AdminTokenStore.EqualsConstantTime(supplied, _token)) return Fail("Unauthorized admin request."); + var command = (JsonHelpers.String(request["cmd"] ?? request["command"]) ?? string.Empty).Trim().ToLowerInvariant(); + var id = request["id"]?.DeepClone(); + + JsonObject Ok(JsonNode? body = null, string? message = null) + { + var response = new JsonObject { ["ok"] = true }; + if (id is not null) response["id"] = id.DeepClone(); + if (!string.IsNullOrEmpty(message)) response["message"] = message; + if (body is not null) response["data"] = body; + return response; + } + JsonObject LocalFail(string error) + { + var response = Fail(error); + if (id is not null) response["id"] = id.DeepClone(); + return response; + } + + switch (command) + { + case "ping": return Ok(new JsonObject { ["pong"] = true }); + case "stats": case "status": return Ok(_server.GetAdminStats()); + case "clients": case "users": + { + var clients = _server.GetAdminClients(); + return Ok(new JsonObject { ["total_clients"] = clients.Count, ["clients"] = clients }); + } + case "bans": + { + var bans = new JsonArray(_server.ListBans().Select(x => (JsonNode)new JsonObject { ["ip"] = x.Ip, ["reason"] = x.Reason, ["bannedAt"] = x.BannedAt }).ToArray()); + return Ok(new JsonObject { ["bans"] = bans }); + } + case "kick": + { + if (!JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId)) return LocalFail("kick requires playerId"); + var result = await _server.KickAsync(playerId, JsonHelpers.String(request["reason"]) ?? string.Empty).ConfigureAwait(false); + return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message); + } + case "ban": + { + var reason = JsonHelpers.String(request["reason"]) ?? string.Empty; + if (JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId)) + { + var result = await _server.BanPlayerAsync(playerId, reason).ConfigureAwait(false); + return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message); + } + var ip = JsonHelpers.String(request["ip"]); + if (string.IsNullOrWhiteSpace(ip)) return LocalFail("ban requires playerId or ip"); + var ban = await _server.BanIpAsync(ip, reason).ConfigureAwait(false); + return ban.Ok ? Ok(ban.Data, ban.Message) : LocalFail(ban.Message); + } + case "unban": + { + var ip = JsonHelpers.String(request["ip"]); + if (string.IsNullOrWhiteSpace(ip)) return LocalFail("unban requires ip"); + var result = _server.Unban(ip); + return result.Ok ? Ok(new JsonObject { ["ip"] = ip }, result.Message) : LocalFail(result.Message); + } + case "world_time": + { + var hhmm = JsonHelpers.String(request["hhmm"] ?? request["time"]); + if (string.IsNullOrWhiteSpace(hhmm)) return LocalFail("world_time requires hhmm"); + return await _server.SetServerTimeAsync(hhmm).ConfigureAwait(false) ? Ok(message: $"Server time set to {hhmm}.") : LocalFail("Invalid time format. Use HHmm (e.g., 1430 for 14:30)."); + } + case "world_weather": + { + var weather = JsonHelpers.String(request["weather"] ?? request["fw"]); + if (string.IsNullOrWhiteSpace(weather)) return LocalFail("world_weather requires weather"); + return await _server.SetServerWeatherAsync(weather).ConfigureAwait(false) ? Ok(message: $"Server weather updated to {weather}.") : LocalFail("Invalid weather ID. Use an 8-digit hex form ID."); + } + default: return LocalFail($"Unknown admin command: {(string.IsNullOrEmpty(command) ? "(empty)" : command)}"); + } + } + + private static JsonObject Fail(string error) => new() { ["ok"] = false, ["error"] = error }; + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + try { _listener?.Stop(); } catch { } + if (_acceptTask is not null) { try { await _acceptTask.ConfigureAwait(false); } catch { } } + Task[] tasks; lock (_gate) tasks = _clientTasks.ToArray(); + if (tasks.Length > 0) { try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2)); } catch { } } + _shutdown.Dispose(); + } +} + +internal static class AdminClient +{ + public static async Task SendAsync(JsonObject request, int port, string tokenPath, CancellationToken cancellationToken = default) + { + var authenticated = (JsonObject)request.DeepClone(); + authenticated["adminToken"] = AdminTokenStore.Load(tokenPath); + var encoded = JsonSerializer.SerializeToUtf8Bytes(authenticated); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, cancellationToken).ConfigureAwait(false); + var stream = client.GetStream(); + await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + using var line = new MemoryStream(); + var one = new byte[1]; + while (line.Length <= ProtocolConstants.MaxMessageBytes) + { + var count = await stream.ReadAsync(one, cancellationToken).ConfigureAwait(false); + if (count == 0) throw new IOException("Admin server closed the connection without a response."); + if (one[0] == (byte)'\n') break; + line.WriteByte(one[0]); + } + if (line.Length > ProtocolConstants.MaxMessageBytes) throw new InvalidDataException("Admin response exceeded the maximum size."); + return JsonNode.Parse(line.ToArray()) as JsonObject ?? throw new InvalidDataException("Admin response must be a JSON object."); + } +} + +internal sealed class LanDiscoveryService : IAsyncDisposable +{ + public const int DiscoveryPort = 7778; + public const string ProtocolName = "commonwealth-online"; + private readonly AuthoritativeServer _server; + private readonly ServerOptions _options; + private readonly CancellationTokenSource _shutdown = new(); + private UdpClient? _udp; + private Task? _task; + + public LanDiscoveryService(AuthoritativeServer server, ServerOptions options) { _server = server; _options = options; } + + public void Start() + { + _udp = new UdpClient(new IPEndPoint(IPAddress.Any, DiscoveryPort)); + _udp.EnableBroadcast = true; + _task = Task.Run(() => RunAsync(_shutdown.Token)); + _server.Log($"LAN discovery listening on UDP port {DiscoveryPort}"); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + UdpReceiveResult result; + try { result = await _udp!.ReceiveAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { continue; } + JsonObject? request; + try { request = JsonNode.Parse(result.Buffer) as JsonObject; } catch (JsonException) { continue; } + if (request is null || JsonHelpers.String(request["type"]) != "discover" || JsonHelpers.String(request["protocol"]) != ProtocolName) continue; + var core = _server.GetCoreStats(); + var response = new JsonObject + { + ["type"] = "discoverResponse", ["protocol"] = ProtocolName, ["version"] = 1, ["name"] = _options.ServerName, + ["description"] = _options.ServerDescription, ["port"] = _options.Port, ["players"] = core["connectedClients"]?.DeepClone(), ["maxPlayers"] = _options.MaxPlayers + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(response); + try { await _udp.SendAsync(bytes, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); } catch { } + } + } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + _udp?.Dispose(); + if (_task is not null) { try { await _task.ConfigureAwait(false); } catch { } } + _shutdown.Dispose(); + } +} diff --git a/server/AssemblyInfo.cs b/server/AssemblyInfo.cs new file mode 100644 index 0000000..f325ae5 --- /dev/null +++ b/server/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Tests")] diff --git a/server/AuthoritativeServer.cs b/server/AuthoritativeServer.cs new file mode 100644 index 0000000..2dd3686 --- /dev/null +++ b/server/AuthoritativeServer.cs @@ -0,0 +1,836 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable +{ + private const int MaxPacketsPerSecond = 120; + private const int MaxConnectAttempts = 8; + private const double ConnectAttemptWindowSeconds = 10.0; + private const double ClientIdleTimeoutSeconds = 60.0; + private const double ClientHandshakeTimeoutSeconds = 10.0; + + private readonly ServerOptions _options; + private readonly BanStore _banStore; + private readonly object _gate = new(); + private readonly Dictionary _clients = new(StringComparer.Ordinal); + private readonly Dictionary _lastPlayerStateByPlayerId = new(); + private readonly Dictionary _lastNpcStateByScope = new(); + private readonly NpcAuthorityManager _npcAuthority = new(); + private readonly Dictionary> _connectAttempts = new(StringComparer.Ordinal); + private readonly Dictionary _stats = new(StringComparer.Ordinal); + private readonly Dictionary _serverWorldState = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _shutdown = new(); + private readonly Task _maintenanceTask; + private JsonObject? _lastLegacyNpcState; + private uint _nextPlayerId = 1; + private uint? _worldStateHostPlayerId; + private readonly double _startedAt = JsonHelpers.UnixTime(); + + public AuthoritativeServer(ServerOptions options) + { + _options = options; + _banStore = new BanStore(options.BansPath); + foreach (var name in new[] + { + "clientsConnected", "clientsDisconnected", "pendingConnectionsRejected", "packetsReceived", "packetsSent", "packetsBroadcast", + "packetsRejected", "rateLimitedPackets", "transformPacketsReceived", "transformPacketsBroadcast", "transformPacketsInterestFiltered", + "playerStatePacketsReceived", "playerStatePacketsBroadcast", "movementPacketsRejected", "movementCorrectionsSent", + "worldStatePacketsReceived", "worldStatePacketsBroadcast", "npcStatePacketsReceived", "npcStatePacketsBroadcast", + "npcAuthorityChanges", "npcAuthorityRejects", "combatHitsReceived", "combatHitsRouted", "worldStateHostPacketsBroadcast", + "serverWorldStatePacketsBroadcast", "sessionEndedPacketsSent", "bannedConnectionsRejected", "disconnectPacketsBroadcast", + "protocolV2Connections", "legacyConnections" + }) _stats[name] = 0; + _maintenanceTask = Task.Run(() => MaintenanceLoopAsync(_shutdown.Token)); + } + + public event Action? LogMessage; + + public void Log(string message, string level = "info") + { + if (!ShouldLog(level)) return; + LogMessage?.Invoke(message, level); + } + + private bool ShouldLog(string level) + { + static int Rank(string value) => value switch { "debug" => 10, "info" => 20, "warning" => 30, "error" => 40, _ => 20 }; + return Rank(level) >= Rank(_options.LogVerbosity); + } + + public async Task AcceptConnectionAsync(IGameConnection connection, CancellationToken cancellationToken) + { + var ip = connection.RemoteEndpoint.Address.ToString(); + var ban = _banStore.GetBan(ip); + if (ban is not null) + { + Increment("bannedConnectionsRejected"); + await SendDirectSessionEndedAsync(connection, "banned", ban.Value.Reason, cancellationToken).ConfigureAwait(false); + await connection.DisconnectAsync(0, "Banned"); + return false; + } + if (!AllowConnectAttempt(ip)) + { + Increment("pendingConnectionsRejected"); + await SendDirectSessionEndedAsync(connection, "rate_limited", "Too many connection attempts.", cancellationToken).ConfigureAwait(false); + await connection.DisconnectAsync(0, "Connection attempt rate limited"); + return false; + } + + ClientSession client; + lock (_gate) + { + var maxPending = Math.Max(16, _options.MaxPlayers * 2); + var pending = _clients.Values.Count(x => !x.GameplayActive); + if (pending >= maxPending) client = null!; + else + { + client = new ClientSession(connection, _nextPlayerId++); + _clients[connection.ConnectionKey] = client; + } + } + if (client is null) + { + Increment("pendingConnectionsRejected"); + await SendDirectSessionEndedAsync(connection, "rate_limited", "Too many pending connections.", cancellationToken).ConfigureAwait(false); + await connection.DisconnectAsync(0, "Too many pending connections"); + return false; + } + + var capabilities = new JsonArray("interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1", "movement-correction-v1", "npc-authority-epoch-v1", "player-state-v1"); + if (connection.TransportName == "gns") + { + capabilities.Add("gns-message-transport-v1"); + capabilities.Add("gns-snapshot-sequence-v1"); + } + var welcome = new JsonObject + { + ["type"] = "welcome", + ["playerId"] = client.PlayerId, + ["serverTime"] = JsonHelpers.UnixTime(), + ["serverName"] = _options.ServerName, + ["serverDescription"] = _options.ServerDescription, + ["protocolVersion"] = ProtocolConstants.ProtocolVersion, + ["capabilities"] = capabilities + }; + Log($"{connection.TransportName.ToUpperInvariant()} accept: {client.Label} (provisional player {client.PlayerId})", "debug"); + if (!await SendPacketAsync(client, welcome, false, cancellationToken).ConfigureAwait(false)) + { + await DisconnectClientAsync(client).ConfigureAwait(false); + return false; + } + return true; + } + + public async Task HandleMessageAsync(IGameConnection connection, ReadOnlyMemory payload, CancellationToken cancellationToken) + { + var client = FindByConnection(connection); + if (client is null) { await connection.DisconnectAsync(0, "Unknown session"); return; } + if (!await AllowPacketAsync(client).ConfigureAwait(false)) return; + client.RecordReceived(); + Increment("packetsReceived"); + + JsonObject packet; + try { packet = PacketCodec.Decode(payload.Span); } + catch (PacketCodecException ex) { Reject(client, ex.Message); return; } + await DispatchPacketAsync(client, packet, cancellationToken).ConfigureAwait(false); + } + + public async Task HandleTransportRejectAsync(IGameConnection connection, string reason, bool warning = false) + { + var client = FindByConnection(connection); + if (client is null) return; + if (!await AllowPacketAsync(client).ConfigureAwait(false)) return; + client.RecordReceived(); + Increment("packetsReceived"); + Reject(client, reason, warning); + } + + public Task HandleConnectionClosedAsync(IGameConnection connection) + { + var client = FindByConnection(connection); + return client is null ? Task.CompletedTask : DisconnectClientAsync(client); + } + + public async Task EndSessionForTransportAsync(IGameConnection connection, string code, string reason) + { + var client = FindByConnection(connection); + if (client is null) + { + await SendDirectSessionEndedAsync(connection, code, reason, CancellationToken.None).ConfigureAwait(false); + await connection.DisconnectAsync(0, reason); + return; + } + await EndSessionAsync(client, code, reason).ConfigureAwait(false); + } + + private async Task DispatchPacketAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var packetType = JsonHelpers.String(packet["type"]); + if (packetType == "hello") { await HandleHelloAsync(client, packet, cancellationToken).ConfigureAwait(false); return; } + if (packetType == "keepAlive") return; + + if (!client.GameplayActive) + { + if (packetType is not ("transform" or "worldState" or "npcState" or "combatHit")) + { + Reject(client, "Gameplay packet received before session activation"); + return; + } + if (!await ActivateClientAsync(client, ProtocolConstants.LegacyProtocolVersion, cancellationToken).ConfigureAwait(false)) + { + await EndSessionAsync(client, "server_full", "Server is full.").ConfigureAwait(false); + return; + } + Log($"Legacy client player {client.PlayerId} activated without hello; client upgrade recommended.", "warning"); + } + + switch (packetType) + { + case "transform": await HandleTransformAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "playerState": await HandlePlayerStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "worldState": await HandleWorldStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "npcState": await HandleNpcStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "combatHit": await HandleCombatHitAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + default: Reject(client, $"Unknown packet type: {packetType ?? ""}"); break; + } + } + + private async Task HandleHelloAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + if (!JsonHelpers.TryUInt32(packet["protocolVersion"], 1, ushort.MaxValue, out var version) || version != ProtocolConstants.ProtocolVersion) + { + await EndSessionAsync(client, "protocol_mismatch", $"Server requires protocol {ProtocolConstants.ProtocolVersion}.").ConfigureAwait(false); + return; + } + if (!await ActivateClientAsync(client, (int)version, cancellationToken).ConfigureAwait(false)) + await EndSessionAsync(client, "server_full", "Server is full.").ConfigureAwait(false); + } + + private async Task ActivateClientAsync(ClientSession client, int protocolVersion, CancellationToken cancellationToken) + { + bool becameHost; + lock (_gate) + { + if (client.GameplayActive) return true; + if (_clients.Values.Count(x => x.GameplayActive) >= _options.MaxPlayers) return false; + if (!client.Activate(protocolVersion)) return true; + _stats["clientsConnected"]++; + _stats[protocolVersion >= ProtocolConstants.ProtocolVersion ? "protocolV2Connections" : "legacyConnections"]++; + becameHost = _worldStateHostPlayerId is null; + if (becameHost) _worldStateHostPlayerId = client.PlayerId; + } + + Log($"Client connected: {client.Label} (player {client.PlayerId}, protocol {protocolVersion})"); + var ready = new JsonObject + { + ["type"] = "sessionReady", ["playerId"] = client.PlayerId, ["protocolVersion"] = protocolVersion, + ["serverProtocolVersion"] = ProtocolConstants.ProtocolVersion, ["worldStateHostPlayerId"] = _worldStateHostPlayerId, + ["serverTime"] = JsonHelpers.UnixTime() + }; + if (!await SendPacketAsync(client, ready, false, cancellationToken).ConfigureAwait(false)) return false; + await SendExistingTransformsAsync(client, cancellationToken).ConfigureAwait(false); + await SendExistingPlayerStatesAsync(client, cancellationToken).ConfigureAwait(false); + await SendExistingNpcStateAsync(client, cancellationToken).ConfigureAwait(false); + if (becameHost) await BroadcastWorldStateHostAsync(client.PlayerId, cancellationToken).ConfigureAwait(false); + return true; + } + + private async Task HandleTransformAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var normalized = ProtocolValidation.NormalizeTransform(packet); + if (normalized is null) { Reject(client, "Malformed transform"); return; } + if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + foreach (var field in new[] { "equippedItems", "appearance", "actionEvents", "characterName" }) normalized.Remove(field); + var previous = client.TransformAnchor(); + var acceptedMonotonic = MonotonicClock.Now; + var movement = ProtocolValidation.ValidateMovement(previous.Transform, previous.Monotonic, normalized, acceptedMonotonic); + if (!movement.Accepted) + { + Increment("movementPacketsRejected"); + Reject(client, movement.Reason); + await SendPositionCorrectionAsync(client, movement.Reason, cancellationToken).ConfigureAwait(false); + return; + } + var previousScope = AuthorityScopeFromTransform(previous.Transform); + normalized["playerId"] = client.PlayerId; + normalized["serverTime"] = JsonHelpers.UnixTime(); + client.RecordTransform(normalized, acceptedMonotonic); + Increment("transformPacketsReceived"); + await BroadcastTransformAsync(client, normalized, cancellationToken).ConfigureAwait(false); + await ReconcileNpcAuthorityAsync(cancellationToken).ConfigureAwait(false); + var currentScope = AuthorityScopeFromTransform(normalized); + if (currentScope is not null && currentScope != previousScope) await SendNpcAuthorityForClientAsync(client, currentScope.Value, cancellationToken).ConfigureAwait(false); + } + + private async Task HandlePlayerStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + if (client.ProtocolVersion < ProtocolConstants.ProtocolVersion) { Reject(client, "playerState requires Protocol V2", false); return; } + var normalized = ProtocolValidation.NormalizePlayerState(packet); + if (normalized is null) { Reject(client, "Malformed playerState"); return; } + normalized["playerId"] = client.PlayerId; + normalized["serverTime"] = JsonHelpers.UnixTime(); + var durable = new[] { "equippedItems", "appearance", "characterName" }; + lock (_gate) + { + if (durable.Any(normalized.ContainsKey)) + { + var cached = _lastPlayerStateByPlayerId.TryGetValue(client.PlayerId, out var existing) ? JsonHelpers.CloneObject(existing) : new JsonObject(); + cached["type"] = "playerState"; cached["playerId"] = client.PlayerId; cached["serverTime"] = normalized["serverTime"]?.DeepClone(); + foreach (var field in durable) if (normalized.ContainsKey(field)) cached[field] = normalized[field]?.DeepClone(); + cached.Remove("actionEvents"); + _lastPlayerStateByPlayerId[client.PlayerId] = cached; + } + _stats["playerStatePacketsReceived"]++; + } + await BroadcastPlayerStateAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleWorldStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + lock (_gate) if (_worldStateHostPlayerId != client.PlayerId) { RejectLocked(client, "worldState from non-authority client", false); return; } + var normalized = ProtocolValidation.NormalizeWorldState(packet); + if (normalized is null) { Reject(client, "Malformed worldState"); return; } + normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime(); + Increment("worldStatePacketsReceived"); + await BroadcastWorldStateAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleNpcStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var normalized = ProtocolValidation.NormalizeNpcState(packet); + if (normalized is null) { Reject(client, "Malformed npcState"); return; } + if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + { + var scope = AuthorityScopeFromPacket(normalized); + if (scope is null || !JsonHelpers.TryUInt32(normalized["authorityEpoch"], 1, uint.MaxValue, out var epoch)) + { + Increment("npcAuthorityRejects"); Reject(client, "Protocol V2 npcState missing valid authority scope/epoch"); return; + } + lock (_gate) + { + if (!_npcAuthority.Authorize(client.PlayerId, scope.Value, epoch)) { _stats["npcAuthorityRejects"]++; RejectLocked(client, "Stale or unauthorized npcState authority epoch"); return; } + } + var npcs = (JsonArray)normalized["npcs"]!; + foreach (var node in npcs) + { + var npc = (JsonObject)node!; + if (JsonHelpers.String(npc["cellId"]) != scope.Value.CellId || (JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty) != scope.Value.WorldspaceId) + { + Increment("npcAuthorityRejects"); Reject(client, "npcState contains NPCs outside declared authority scope"); return; + } + } + normalized["authorityCellId"] = scope.Value.CellId; normalized["authorityWorldspaceId"] = scope.Value.WorldspaceId; + lock (_gate) _lastNpcStateByScope[scope.Value] = JsonHelpers.CloneObject(normalized); + } + else + { + lock (_gate) if (_worldStateHostPlayerId != client.PlayerId) { RejectLocked(client, "Legacy npcState from non-authority client", false); return; } + lock (_gate) _lastLegacyNpcState = JsonHelpers.CloneObject(normalized); + } + normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime(); normalized["fullReplace"] = true; + Increment("npcStatePacketsReceived"); + await BroadcastNpcStateAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleCombatHitAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var normalized = ProtocolValidation.NormalizeCombatHit(packet); + if (normalized is null) { Reject(client, "Malformed combatHit"); return; } + JsonHelpers.TryUInt32(normalized["sequence"], 1, uint.MaxValue, out var sequence); + if (sequence <= client.LastCombatSequence) { Reject(client, "Duplicate or out-of-order combat sequence", false); return; } + client.LastCombatSequence = sequence; + normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime(); + Increment("combatHitsReceived"); + await RouteCombatHitAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task AllowPacketAsync(ClientSession client) + { + var now = MonotonicClock.Now; + if (now - client.RateWindowStart >= 1.0) + { + if (client.RateWindowCount <= MaxPacketsPerSecond) client.RateViolations = Math.Max(0, client.RateViolations - 1); + client.RateWindowStart = now; client.RateWindowCount = 0; client.RateWindowBlocked = false; + } + client.RateWindowCount++; + if (client.RateWindowCount <= MaxPacketsPerSecond) return true; + Increment("rateLimitedPackets"); + if (!client.RateWindowBlocked) + { + client.RateWindowBlocked = true; client.RateViolations++; + Log($"Packet-rate limit exceeded by player {client.PlayerId} ({client.RateViolations}/3 windows)", "warning"); + } + if (client.RateViolations >= 3) await EndSessionAsync(client, "rate_limited", "Sustained packet-rate limit exceeded.").ConfigureAwait(false); + return false; + } + + private bool AllowConnectAttempt(string ip) + { + var now = MonotonicClock.Now; + var cutoff = now - ConnectAttemptWindowSeconds; + lock (_gate) + { + if (!_connectAttempts.TryGetValue(ip, out var queue)) _connectAttempts[ip] = queue = new Queue(); + while (queue.Count > 0 && queue.Peek() < cutoff) queue.Dequeue(); + queue.Enqueue(now); + return queue.Count <= MaxConnectAttempts; + } + } + + private async Task SendPositionCorrectionAsync(ClientSession client, string reason, CancellationToken cancellationToken) + { + var previous = client.TransformAnchor().Transform; + if (previous is null) return; + var packet = new JsonObject + { + ["type"] = "positionCorrection", ["reason"] = reason[..Math.Min(reason.Length, 160)], + ["x"] = previous["x"]?.DeepClone(), ["y"] = previous["y"]?.DeepClone(), ["z"] = previous["z"]?.DeepClone(), + ["angleZ"] = previous["angleZ"]?.DeepClone(), ["cellId"] = previous["cellId"]?.DeepClone(), + ["worldspaceId"] = previous["worldspaceId"]?.DeepClone(), ["serverTime"] = JsonHelpers.UnixTime() + }; + if (await SendPacketAsync(client, packet, false, cancellationToken).ConfigureAwait(false)) Increment("movementCorrectionsSent"); + else await DisconnectClientAsync(client).ConfigureAwait(false); + } + + private async Task SendExistingTransformsAsync(ClientSession target, CancellationToken cancellationToken) + { + var targetTransform = target.TransformAnchor().Transform; + ClientSession[] peers; + lock (_gate) peers = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != target.Connection.ConnectionKey && x.LastTransform is not null).ToArray(); + var sent = 0; + foreach (var peer in peers) + { + var transform = peer.TransformAnchor().Transform; + if (!ProtocolValidation.StatesShareInterest(transform, targetTransform)) continue; + if (transform is null) continue; + transform["serverTime"] = JsonHelpers.UnixTime(); + if (!await SendPacketAsync(target, transform, true, cancellationToken).ConfigureAwait(false)) break; + sent++; + } + Add("transformPacketsBroadcast", sent); + } + + private async Task SendExistingPlayerStatesAsync(ClientSession target, CancellationToken cancellationToken) + { + if (target.ProtocolVersion < ProtocolConstants.ProtocolVersion) return; + JsonObject[] packets; + lock (_gate) packets = _lastPlayerStateByPlayerId.Where(x => x.Key != target.PlayerId).Select(x => JsonHelpers.CloneObject(x.Value)).ToArray(); + var sent = 0; + foreach (var packet in packets) + { + packet["serverTime"] = JsonHelpers.UnixTime(); + if (!await SendPacketAsync(target, packet, true, cancellationToken).ConfigureAwait(false)) break; + sent++; + } + Add("playerStatePacketsBroadcast", sent); + } + + private async Task BroadcastTransformAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey).ToArray(); + var sent = 0; var filtered = 0; + foreach (var recipient in recipients) + { + if (!ProtocolValidation.StatesShareInterest(packet, recipient.TransformAnchor().Transform)) { filtered++; continue; } + if (await SendPacketAsync(recipient, packet, true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + Add("transformPacketsBroadcast", sent); Add("transformPacketsInterestFiltered", filtered); + } + + private async Task BroadcastPlayerStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey && x.ProtocolVersion >= ProtocolConstants.ProtocolVersion).ToArray(); + var durable = new[] { "equippedItems", "appearance", "characterName" }; + var senderTransform = sender.TransformAnchor().Transform; + var senderScopeKnown = ProtocolValidation.ScopeFromState(senderTransform) is not null; + var sent = 0; + foreach (var recipient in recipients) + { + var relay = JsonHelpers.CloneObject(packet); + if (relay.ContainsKey("actionEvents")) + { + var targetTransform = recipient.TransformAnchor().Transform; + var targetScopeKnown = ProtocolValidation.ScopeFromState(targetTransform) is not null; + if (!senderScopeKnown || !targetScopeKnown || !ProtocolValidation.StatesShareInterest(senderTransform, targetTransform)) relay.Remove("actionEvents"); + } + if (!relay.ContainsKey("actionEvents") && !durable.Any(relay.ContainsKey)) continue; + if (await SendPacketAsync(recipient, relay, true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + Add("playerStatePacketsBroadcast", sent); + } + + private async Task BroadcastWorldStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + var sent = await BroadcastAsync(packet, sender, false, cancellationToken).ConfigureAwait(false); + Add("worldStatePacketsBroadcast", sent); + } + + private JsonObject NpcPacketForRecipient(JsonObject packet, ClientSession recipient) + { + var target = recipient.TransformAnchor().Transform; + if (target is null) return JsonHelpers.CloneObject(packet); + var clean = JsonHelpers.CloneObject(packet); + var output = new JsonArray(); + if (packet["npcs"] is JsonArray npcs) + foreach (var node in npcs) if (node is JsonObject npc && ProtocolValidation.StatesShareInterest(npc, target)) output.Add(npc.DeepClone()); + clean["npcs"] = output; + return clean; + } + + private async Task BroadcastNpcStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey).ToArray(); + var sent = 0; + foreach (var recipient in recipients) + { + if (await SendPacketAsync(recipient, NpcPacketForRecipient(packet, recipient), true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + Add("npcStatePacketsBroadcast", sent); + } + + private async Task SendExistingNpcStateAsync(ClientSession client, CancellationToken cancellationToken) + { + JsonObject? snapshot; + if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + { + var scope = AuthorityScopeFromTransform(client.TransformAnchor().Transform); + lock (_gate) snapshot = scope is not null && _lastNpcStateByScope.TryGetValue(scope.Value, out var stored) ? JsonHelpers.CloneObject(stored) : null; + } + else lock (_gate) snapshot = _lastLegacyNpcState is null ? null : JsonHelpers.CloneObject(_lastLegacyNpcState); + if (snapshot is null || (JsonHelpers.TryUInt32(snapshot["playerId"], 0, uint.MaxValue, out var owner) && owner == client.PlayerId)) return; + var scoped = NpcPacketForRecipient(snapshot, client); scoped["serverTime"] = JsonHelpers.UnixTime(); + if (await SendPacketAsync(client, scoped, true, cancellationToken).ConfigureAwait(false)) Increment("npcStatePacketsBroadcast"); + } + + private async Task RouteCombatHitAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var targetId); + if (targetId == sender.PlayerId) { Reject(sender, "Self-targeted combatHit", false); return; } + ClientSession? recipient; + lock (_gate) recipient = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == targetId); + if (recipient is null) { Reject(sender, $"Combat target {targetId} is not connected", false); return; } + if (!ProtocolValidation.StatesShareInterest(sender.TransformAnchor().Transform, recipient.TransformAnchor().Transform)) { Reject(sender, $"Combat target {targetId} is outside interest scope", true); return; } + if (await SendPacketAsync(recipient, packet, false, cancellationToken).ConfigureAwait(false)) Increment("combatHitsRouted"); + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + + private async Task ReconcileNpcAuthorityAsync(CancellationToken cancellationToken) + { + IReadOnlyList changes; + lock (_gate) + { + var players = _clients.Values.Where(x => x.GameplayActive && x.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + .Select(x => (x.PlayerId, Scope: AuthorityScopeFromTransform(x.TransformAnchor().Transform))) + .Where(x => x.Scope is not null).Select(x => (x.PlayerId, x.Scope!.Value)); + changes = _npcAuthority.Reconcile(players); + foreach (var change in changes) _lastNpcStateByScope.Remove(change.Scope); + } + foreach (var change in changes) + { + var packet = AuthorityPacket(change.PlayerId, change.Epoch, change.Scope); + await BroadcastAsync(packet, null, true, cancellationToken).ConfigureAwait(false); + Increment("npcAuthorityChanges"); + Log($"NPC authority scope {change.Scope.CellId}/{(string.IsNullOrEmpty(change.Scope.WorldspaceId) ? "" : change.Scope.WorldspaceId)}: {change.PreviousPlayerId} -> {change.PlayerId}, epoch {change.Epoch}"); + } + } + + private async Task SendNpcAuthorityForClientAsync(ClientSession client, ScopeKey scope, CancellationToken cancellationToken) + { + if (client.ProtocolVersion < ProtocolConstants.ProtocolVersion) return; + AuthorityAssignment? assignment; + lock (_gate) assignment = _npcAuthority.Get(scope); + if (assignment is null) return; + if (!await SendPacketAsync(client, AuthorityPacket(assignment.Value.PlayerId, assignment.Value.Epoch, scope), false, cancellationToken).ConfigureAwait(false)) await DisconnectClientAsync(client).ConfigureAwait(false); + } + + private static JsonObject AuthorityPacket(uint playerId, uint epoch, ScopeKey scope) => new() + { + ["type"] = "npcAuthority", ["authorityPlayerId"] = playerId, ["authorityEpoch"] = epoch, + ["authorityCellId"] = scope.CellId, ["authorityWorldspaceId"] = scope.WorldspaceId, ["serverTime"] = JsonHelpers.UnixTime() + }; + + private static ScopeKey? AuthorityScopeFromTransform(JsonObject? transform) + { + if (transform is null) return null; + var cell = JsonHelpers.String(transform["cellId"]); var world = JsonHelpers.String(transform["worldspaceId"]) ?? string.Empty; + return cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true) + ? null : new ScopeKey(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world)); + } + + private static ScopeKey? AuthorityScopeFromPacket(JsonObject packet) + { + var cell = JsonHelpers.String(packet["authorityCellId"]); var world = JsonHelpers.String(packet["authorityWorldspaceId"]) ?? string.Empty; + return cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true) + ? null : new ScopeKey(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world)); + } + + private async Task BroadcastAsync(JsonObject packet, ClientSession? exclude, bool v2Only, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && (exclude is null || x.Connection.ConnectionKey != exclude.Connection.ConnectionKey) && (!v2Only || x.ProtocolVersion >= ProtocolConstants.ProtocolVersion)).ToArray(); + var sent = 0; + foreach (var recipient in recipients) + { + if (await SendPacketAsync(recipient, packet, true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + return sent; + } + + private async Task BroadcastWorldStateHostAsync(uint playerId, CancellationToken cancellationToken) + { + var sent = await BroadcastAsync(new JsonObject { ["type"] = "worldStateHost", ["worldStateHostPlayerId"] = playerId, ["serverTime"] = JsonHelpers.UnixTime() }, null, false, cancellationToken).ConfigureAwait(false); + Add("worldStateHostPacketsBroadcast", sent); + } + + private async Task BroadcastDisconnectAsync(ClientSession client) + { + var sent = await BroadcastAsync(new JsonObject { ["type"] = "disconnect", ["playerId"] = client.PlayerId, ["serverTime"] = JsonHelpers.UnixTime() }, null, false, CancellationToken.None).ConfigureAwait(false); + Add("disconnectPacketsBroadcast", sent); + } + + private async Task SendPacketAsync(ClientSession client, JsonObject packet, bool broadcast, CancellationToken cancellationToken) + { + EncodedPacket encoded; + try { encoded = PacketCodec.Encode(packet); } + catch (PacketCodecException) { return false; } + var result = await client.Connection.SendAsync(encoded, cancellationToken).ConfigureAwait(false); + var success = result == SendOutcome.Sent || (encoded.Delivery == Delivery.UnreliableSequenced && result is SendOutcome.Dropped or SendOutcome.Backpressure); + if (!success) return false; + client.RecordSent(broadcast); + lock (_gate) { _stats["packetsSent"]++; if (broadcast) _stats["packetsBroadcast"]++; } + return true; + } + + private async Task SendDirectSessionEndedAsync(IGameConnection connection, string code, string reason, CancellationToken cancellationToken) + { + var packet = new JsonObject { ["type"] = "sessionEnded", ["code"] = code, ["reason"] = reason ?? string.Empty, ["serverTime"] = JsonHelpers.UnixTime() }; + try + { + var result = await connection.SendAsync(PacketCodec.Encode(packet), cancellationToken).ConfigureAwait(false); + if (result == SendOutcome.Sent) lock (_gate) { _stats["sessionEndedPacketsSent"]++; _stats["packetsSent"]++; } + } + catch { } + } + + private async Task EndSessionAsync(ClientSession client, string code, string reason) + { + var packet = new JsonObject { ["type"] = "sessionEnded", ["code"] = code, ["reason"] = reason ?? string.Empty, ["serverTime"] = JsonHelpers.UnixTime() }; + if (await SendPacketAsync(client, packet, false, CancellationToken.None).ConfigureAwait(false)) Increment("sessionEndedPacketsSent"); + await DisconnectClientAsync(client).ConfigureAwait(false); + } + + private async Task DisconnectClientAsync(ClientSession client) + { + bool removed; bool wasActive; bool wasHost; + lock (_gate) + { + wasActive = client.GameplayActive; wasHost = wasActive && _worldStateHostPlayerId == client.PlayerId; + removed = _clients.Remove(client.Connection.ConnectionKey); + if (removed) _lastPlayerStateByPlayerId.Remove(client.PlayerId); + if (removed && wasActive) _stats["clientsDisconnected"]++; + } + if (!removed) return; + await client.Connection.DisconnectAsync(0, "Commonwealth Online disconnect"); + if (!wasActive) { Log($"Closed pending/probe connection: {client.Label}", "debug"); return; } + Log($"Client disconnected: {client.Label} (player {client.PlayerId})"); + await BroadcastDisconnectAsync(client).ConfigureAwait(false); + await ReconcileNpcAuthorityAsync(CancellationToken.None).ConfigureAwait(false); + if (wasHost) await ReassignWorldStateHostAsync().ConfigureAwait(false); + } + + private async Task ReassignWorldStateHostAsync() + { + uint? newHost; + lock (_gate) + { + var active = _clients.Values.Where(x => x.GameplayActive).ToArray(); + if (active.Length == 0) { _worldStateHostPlayerId = null; _lastLegacyNpcState = null; return; } + newHost = active.Min(x => x.PlayerId); _worldStateHostPlayerId = newHost; _lastLegacyNpcState = null; + } + await BroadcastWorldStateHostAsync(newHost!.Value, CancellationToken.None).ConfigureAwait(false); + Log($"Reassigned world-state host to player {newHost.Value}."); + } + + private void Reject(ClientSession client, string reason, bool warning = true) + { + lock (_gate) RejectLocked(client, reason, warning); + } + + private void RejectLocked(ClientSession client, string reason, bool warning = true) + { + _stats["packetsRejected"]++; + Log($"Rejected packet from {client.Label} (player {client.PlayerId}): {reason}", warning ? "warning" : "debug"); + } + + private ClientSession? FindByConnection(IGameConnection connection) + { + lock (_gate) return _clients.TryGetValue(connection.ConnectionKey, out var client) ? client : null; + } + + private void Increment(string name) { lock (_gate) _stats[name]++; } + private void Add(string name, long amount) { lock (_gate) _stats[name] += amount; } + + private async Task MaintenanceLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try { await Task.Delay(500, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } + ClientSession[] snapshot; + lock (_gate) snapshot = _clients.Values.ToArray(); + var now = JsonHelpers.UnixTime(); + foreach (var client in snapshot) + { + if (!client.GameplayActive && now - client.ConnectedAt > ClientHandshakeTimeoutSeconds) + { + Log($"Handshake timeout: {client.Label}", "warning"); + await DisconnectClientAsync(client).ConfigureAwait(false); + } + else if (client.GameplayActive && client.LastPacketAt is { } last && now - last > ClientIdleTimeoutSeconds) + { + Log($"Idle timeout: player {client.PlayerId}", "warning"); + await DisconnectClientAsync(client).ConfigureAwait(false); + } + } + } + } + + public JsonObject GetCoreStats() + { + lock (_gate) + { + var active = _clients.Values.Count(x => x.GameplayActive); + var stats = new JsonObject(); + foreach (var pair in _stats) stats[pair.Key] = pair.Value; + stats["host"] = _options.Host; stats["port"] = _options.Port; stats["serverName"] = _options.ServerName; + stats["serverDescription"] = _options.ServerDescription; stats["maxPlayers"] = _options.MaxPlayers; stats["isRunning"] = true; + stats["startedAt"] = _startedAt; stats["uptimeSeconds"] = JsonHelpers.UnixTime() - _startedAt; stats["connectedClients"] = active; + stats["pendingConnections"] = _clients.Count - active; stats["nextPlayerId"] = _nextPlayerId; stats["protocolVersion"] = ProtocolConstants.ProtocolVersion; + stats["npcAuthorityScopes"] = _npcAuthority.Assignments.Count; + return stats; + } + } + + public JsonObject GetAdminStats() + { + var core = GetCoreStats(); + return new JsonObject + { + ["is_running"] = true, ["host"] = _options.Host, ["port"] = _options.Port.ToString(), ["server_name"] = _options.ServerName, + ["server_description"] = _options.ServerDescription, ["uptime_seconds"] = core["uptimeSeconds"]?.DeepClone(), + ["connected_clients"] = core["connectedClients"]?.DeepClone(), ["packets_received"] = core["packetsReceived"]?.DeepClone(), + ["packets_sent"] = core["packetsSent"]?.DeepClone(), ["transform_packets_received"] = core["transformPacketsReceived"]?.DeepClone(), + ["transform_packets_broadcast"] = core["transformPacketsBroadcast"]?.DeepClone(), ["world_state_packets_received"] = core["worldStatePacketsReceived"]?.DeepClone(), + ["world_state_packets_broadcast"] = core["worldStatePacketsBroadcast"]?.DeepClone() + }; + } + + public JsonArray GetClientSnapshots() + { + lock (_gate) return new JsonArray(_clients.Values.Where(x => x.GameplayActive).Select(x => (JsonNode)x.Snapshot()).ToArray()); + } + + public JsonArray GetAdminClients() + { + lock (_gate) + { + return new JsonArray(_clients.Values.Where(x => x.GameplayActive).Select(client => + { + var snapshot = client.Snapshot(); + var endpoint = client.Label; + return (JsonNode)new JsonObject + { + ["player_id"] = client.PlayerId, ["address"] = endpoint, ["label"] = endpoint, ["connected_at"] = client.ConnectedAt, + ["packets_sent"] = client.PacketsSent, ["packets_received"] = client.PacketsReceived, ["last_transform"] = snapshot["lastTransform"]?.DeepClone() + }; + }).ToArray()); + } + } + + public IReadOnlyList ListBans() => _banStore.List(); + + public async Task<(bool Ok, string Message, JsonObject? Data)> KickAsync(uint playerId, string reason) + { + ClientSession? client; lock (_gate) client = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == playerId); + if (client is null) return (false, $"No connected player with id {playerId}", null); + var data = new JsonObject { ["playerId"] = playerId, ["ip"] = client.RemoteEndpoint.Address.ToString(), ["code"] = "kicked", ["reason"] = reason ?? string.Empty }; + await EndSessionAsync(client, "kicked", reason ?? string.Empty).ConfigureAwait(false); + return (true, $"Kicked player {playerId}.", data); + } + + public async Task<(bool Ok, string Message, JsonObject? Data)> BanPlayerAsync(uint playerId, string reason) + { + ClientSession? client; lock (_gate) client = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == playerId); + return client is null ? (false, $"No connected player with id {playerId}", null) : await BanIpAsync(client.RemoteEndpoint.Address.ToString(), reason).ConfigureAwait(false); + } + + public async Task<(bool Ok, string Message, JsonObject? Data)> BanIpAsync(string ip, string reason) + { + BanEntry entry; + try { entry = _banStore.Ban(ip, reason ?? string.Empty); } catch (ArgumentException ex) { return (false, ex.Message, null); } + ClientSession[] sessions; lock (_gate) sessions = _clients.Values.Where(x => x.RemoteEndpoint.Address.ToString() == entry.Ip).ToArray(); + foreach (var session in sessions) await EndSessionAsync(session, "banned", entry.Reason).ConfigureAwait(false); + var data = new JsonObject { ["ip"] = entry.Ip, ["reason"] = entry.Reason, ["bannedAt"] = entry.BannedAt, ["sessionsEnded"] = sessions.Length }; + Log($"Banned IP {entry.Ip}{(string.IsNullOrEmpty(entry.Reason) ? string.Empty : $" (reason: {entry.Reason})")}"); + return (true, $"Banned IP {entry.Ip}.", data); + } + + public (bool Ok, string Message) Unban(string ip) + { + var removed = _banStore.Unban(ip); + if (removed) Log($"Unbanned IP {ip}"); + return removed ? (true, $"Unbanned IP {ip}.") : (false, $"IP {ip} is not banned."); + } + + public async Task SetServerTimeAsync(string hhmm) + { + if (WorldStatePresets.HhmmToGameHour(hhmm) is null) return false; + var normalized = hhmm.Trim().PadLeft(4, '0'); + lock (_gate) _serverWorldState["timeHHmm"] = normalized; + await BroadcastServerWorldStateAsync().ConfigureAwait(false); + return true; + } + + public async Task SetServerWeatherAsync(string value) + { + string normalized; + try { normalized = WorldStatePresets.NormalizeWeatherConsoleArg(value); } catch { return false; } + lock (_gate) { _serverWorldState["weatherConsoleArg"] = normalized; _serverWorldState["weatherFormId"] = WorldStatePresets.RelayWeatherFormId(normalized); } + await BroadcastServerWorldStateAsync().ConfigureAwait(false); + return true; + } + + private async Task BroadcastServerWorldStateAsync() + { + Dictionary snapshot; lock (_gate) snapshot = new Dictionary(_serverWorldState, StringComparer.Ordinal); + var packets = new List(); + if (snapshot.TryGetValue("timeHHmm", out var time)) packets.Add(new JsonObject { ["type"] = "serverWorldState", ["timeHHmm"] = time, ["serverTime"] = JsonHelpers.UnixTime() }); + if (snapshot.TryGetValue("weatherConsoleArg", out var weather)) + { + var packet = new JsonObject { ["type"] = "serverWorldState", ["weatherConsoleArg"] = weather, ["serverTime"] = JsonHelpers.UnixTime() }; + if (snapshot.TryGetValue("weatherFormId", out var form)) packet["weatherFormId"] = form; + packets.Add(packet); + } + var sent = 0; + foreach (var packet in packets) sent += await BroadcastAsync(packet, null, false, CancellationToken.None).ConfigureAwait(false); + Add("serverWorldStatePacketsBroadcast", sent); + } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + try { await _maintenanceTask.ConfigureAwait(false); } catch { } + ClientSession[] clients; lock (_gate) { clients = _clients.Values.ToArray(); _clients.Clear(); } + foreach (var client in clients) await client.Connection.DisposeAsync(); + _shutdown.Dispose(); + } +} diff --git a/server/CommonwealthOnline.Server.csproj b/server/CommonwealthOnline.Server.csproj new file mode 100644 index 0000000..f1104f1 --- /dev/null +++ b/server/CommonwealthOnline.Server.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + enable + enable + latest + true + CommonwealthOnline.Server + CommonwealthOnline.Server + + + + + diff --git a/server/Configuration.cs b/server/Configuration.cs new file mode 100644 index 0000000..ebdab92 --- /dev/null +++ b/server/Configuration.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal sealed class ServerOptions +{ + public string Host { get; set; } = "0.0.0.0"; + public int Port { get; set; } = 7777; + public string ServerName { get; set; } = "Commonwealth Online Server"; + public string ServerDescription { get; set; } = string.Empty; + public int MaxPlayers { get; set; } = 16; + public string LogVerbosity { get; set; } = "info"; + public int AdminPort { get; set; } = 7779; + public bool EnableGnsTransport { get; set; } + public string? GnsBridgePath { get; set; } + public string ConfigPath { get; set; } = Path.GetFullPath("commonwealth-server.json"); + + public string BaseDirectory => Path.GetDirectoryName(ConfigPath)!; + public string BansPath => Path.Combine(BaseDirectory, "bans.json"); + public string AdminTokenPath => Path.Combine(BaseDirectory, ".admin-token"); + + public static ServerOptions Load(string path) + { + var fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) throw new FileNotFoundException("Config file not found", fullPath); + var root = JsonNode.Parse(File.ReadAllBytes(fullPath)) as JsonObject ?? throw new InvalidDataException("Config file must contain a JSON object at root level."); + var options = new ServerOptions { ConfigPath = fullPath }; + options.Host = JsonHelpers.String(root["host"]) ?? options.Host; + options.Port = ReadInt(root["port"], options.Port, "port"); + options.ServerName = JsonHelpers.String(root["server_name"] ?? root["serverName"]) ?? options.ServerName; + options.ServerDescription = JsonHelpers.String(root["server_description"] ?? root["serverDescription"]) ?? options.ServerDescription; + options.MaxPlayers = ReadInt(root["max_players"] ?? root["maxPlayers"], options.MaxPlayers, "max_players"); + options.LogVerbosity = (JsonHelpers.String(root["log_verbosity"] ?? root["logVerbosity"]) ?? options.LogVerbosity).Trim().ToLowerInvariant(); + options.AdminPort = ReadInt(root["admin_port"] ?? root["adminPort"], options.AdminPort, "admin_port"); + options.EnableGnsTransport = ReadBool(root["enable_gns_transport"] ?? root["enableGnsTransport"], false, "enable_gns_transport"); + var bridge = JsonHelpers.String(root["gns_bridge_path"] ?? root["gnsBridgePath"]); + options.GnsBridgePath = string.IsNullOrWhiteSpace(bridge) ? null : bridge.Trim(); + return options; + } + + public static ServerOptions CreateDefault(string path) + { + var options = new ServerOptions { ConfigPath = Path.GetFullPath(path) }; + options.Save(); + return options; + } + + public void Save() + { + Directory.CreateDirectory(BaseDirectory); + var root = new JsonObject + { + ["host"] = Host, + ["port"] = Port, + ["server_name"] = ServerName, + ["server_description"] = ServerDescription, + ["max_players"] = MaxPlayers, + ["log_verbosity"] = LogVerbosity, + ["admin_port"] = AdminPort, + ["enable_gns_transport"] = EnableGnsTransport, + ["gns_bridge_path"] = GnsBridgePath + }; + using var stream = File.Create(ConfigPath); + JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true }); + stream.WriteByte((byte)'\n'); + } + + public IReadOnlyList Validate() + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(Host)) + errors.Add("host cannot be empty"); + else if (!TryResolveIpv4(Host, out _)) + errors.Add($"host '{Host}' is not a valid IPv4 address or resolvable hostname"); + else if (EnableGnsTransport && (!IPAddress.TryParse(Host.Trim(), out var gnsAddress) || gnsAddress.AddressFamily != AddressFamily.InterNetwork)) + errors.Add("enable_gns_transport requires host to be an explicit IPv4 bind address such as 0.0.0.0 or 127.0.0.1"); + + if (Port is < 1 or > 65535) errors.Add($"port must be 1-65535, got {Port}"); + if (AdminPort is < 1 or > 65535) errors.Add($"admin_port must be 1-65535, got {AdminPort}"); + else if (AdminPort == Port) errors.Add("admin_port must differ from the game port"); + if (string.IsNullOrEmpty(ServerName)) errors.Add("server_name cannot be empty"); + else if (ServerName.Length > 64) errors.Add($"server_name must be <= 64 characters, got {ServerName.Length}"); + if (ServerDescription.Length > 256) errors.Add($"server_description must be <= 256 characters, got {ServerDescription.Length}"); + if (MaxPlayers < 1) errors.Add($"max_players must be >= 1, got {MaxPlayers}"); + else if (MaxPlayers > 256) errors.Add($"max_players must be <= 256, got {MaxPlayers}"); + if (LogVerbosity is not ("debug" or "info" or "warning" or "error")) errors.Add($"log_verbosity must be debug/info/warning/error, got {LogVerbosity}"); + return errors; + } + + public static bool TryResolveIpv4(string host, out IPAddress address) + { + if (IPAddress.TryParse(host.Trim(), out var parsed) && parsed.AddressFamily == AddressFamily.InterNetwork) + { + address = parsed; + return true; + } + try + { + address = Dns.GetHostAddresses(host.Trim()).First(x => x.AddressFamily == AddressFamily.InterNetwork); + return true; + } + catch + { + address = IPAddress.None; + return false; + } + } + + private static int ReadInt(JsonNode? node, int defaultValue, string field) + { + if (node is null) return defaultValue; + if (node is JsonValue value && value.TryGetValue(out var number)) return number; + if (node is JsonValue textValue && textValue.TryGetValue(out var text) && int.TryParse(text, out number)) return number; + throw new InvalidDataException($"Invalid numeric config field: {field}"); + } + + private static bool ReadBool(JsonNode? node, bool defaultValue, string field) + { + if (node is null) return defaultValue; + if (node is JsonValue value && value.TryGetValue(out var boolean)) return boolean; + if (node is JsonValue intValue && intValue.TryGetValue(out var number) && number is 0 or 1) return number == 1; + if (node is JsonValue textValue && textValue.TryGetValue(out var text)) + { + switch (text.Trim().ToLowerInvariant()) + { + case "1": case "true": case "yes": case "on": return true; + case "0": case "false": case "no": case "off": case "": return false; + } + } + throw new InvalidDataException($"{field} must be a boolean or one of true/false, yes/no, on/off, 1/0"); + } +} diff --git a/server/Domain.cs b/server/Domain.cs new file mode 100644 index 0000000..afed98c --- /dev/null +++ b/server/Domain.cs @@ -0,0 +1,317 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal readonly record struct ScopeKey(string CellId, string WorldspaceId) : IComparable +{ + public int CompareTo(ScopeKey other) + { + var cell = string.Compare(CellId, other.CellId, StringComparison.Ordinal); + return cell != 0 ? cell : string.Compare(WorldspaceId, other.WorldspaceId, StringComparison.Ordinal); + } +} + +internal readonly record struct StateScope(string CellId, string WorldspaceId, double X, double Y); +internal readonly record struct AuthorityAssignment(ScopeKey Scope, uint PlayerId, uint Epoch); +internal readonly record struct AuthorityChange(ScopeKey Scope, uint PreviousPlayerId, uint PlayerId, uint Epoch); + +internal sealed class NpcAuthorityManager +{ + private readonly Dictionary _assignments = new(); + private readonly Dictionary _lastEpoch = new(); + + public void Clear() + { + _assignments.Clear(); + _lastEpoch.Clear(); + } + + public AuthorityAssignment? Get(ScopeKey scope) => _assignments.TryGetValue(scope, out var value) ? value : null; + public IReadOnlyCollection Assignments => _assignments.OrderBy(x => x.Key).Select(x => x.Value).ToArray(); + + public bool Authorize(uint playerId, ScopeKey scope, uint epoch) => + _assignments.TryGetValue(scope, out var assignment) && assignment.PlayerId == playerId && assignment.Epoch == epoch; + + public IReadOnlyList Reconcile(IEnumerable<(uint PlayerId, ScopeKey Scope)> players) + { + var desired = players + .Where(x => x.PlayerId > 0) + .GroupBy(x => x.Scope) + .ToDictionary(group => group.Key, group => group.Min(x => x.PlayerId)); + + var scopes = _assignments.Keys.Concat(desired.Keys).Distinct().OrderBy(x => x).ToArray(); + var changes = new List(); + foreach (var scope in scopes) + { + var previous = _assignments.TryGetValue(scope, out var oldAssignment) ? oldAssignment.PlayerId : 0; + var next = desired.TryGetValue(scope, out var nextPlayer) ? nextPlayer : 0; + if (previous == next) continue; + var epoch = _lastEpoch.TryGetValue(scope, out var last) ? checked(last + 1) : 1; + _lastEpoch[scope] = epoch; + if (next == 0) _assignments.Remove(scope); + else _assignments[scope] = new AuthorityAssignment(scope, next, epoch); + changes.Add(new AuthorityChange(scope, previous, next, epoch)); + } + return changes; + } +} + +internal sealed class ClientSession +{ + private readonly object _gate = new(); + + public ClientSession(IGameConnection connection, uint playerId) + { + Connection = connection; + PlayerId = playerId; + ConnectedAt = JsonHelpers.UnixTime(); + RateWindowStart = MonotonicClock.Now; + } + + public IGameConnection Connection { get; } + public uint PlayerId { get; } + public IPEndPoint RemoteEndpoint => Connection.RemoteEndpoint; + public string Label => $"{RemoteEndpoint.Address}:{RemoteEndpoint.Port}"; + public double ConnectedAt { get; } + public double? LastPacketAt { get; private set; } + public JsonObject? LastTransform { get; private set; } + public double? LastTransformMonotonic { get; private set; } + public long PacketsReceived { get; private set; } + public long PacketsSent { get; private set; } + public long PacketsBroadcast { get; private set; } + public bool GameplayActive { get; private set; } + public int ProtocolVersion { get; private set; } + public double RateWindowStart { get; set; } + public int RateWindowCount { get; set; } + public int RateViolations { get; set; } + public bool RateWindowBlocked { get; set; } + public uint LastCombatSequence { get; set; } + + public void RecordReceived() + { + lock (_gate) + { + LastPacketAt = JsonHelpers.UnixTime(); + PacketsReceived++; + } + } + + public void RecordTransform(JsonObject packet, double acceptedMonotonic) + { + lock (_gate) + { + LastTransform = JsonHelpers.CloneObject(packet); + LastTransformMonotonic = acceptedMonotonic; + } + } + + public (JsonObject? Transform, double? Monotonic) TransformAnchor() + { + lock (_gate) + return (LastTransform is null ? null : JsonHelpers.CloneObject(LastTransform), LastTransformMonotonic); + } + + public void RecordSent(bool broadcast) + { + lock (_gate) + { + PacketsSent++; + if (broadcast) PacketsBroadcast++; + } + } + + public bool Activate(int protocolVersion) + { + lock (_gate) + { + if (GameplayActive) return false; + GameplayActive = true; + ProtocolVersion = protocolVersion; + return true; + } + } + + public JsonObject Snapshot() + { + lock (_gate) + { + return new JsonObject + { + ["playerId"] = PlayerId, + ["address"] = RemoteEndpoint.Address.ToString(), + ["port"] = RemoteEndpoint.Port, + ["connectedAt"] = ConnectedAt, + ["lastPacketAt"] = LastPacketAt, + ["lastTransform"] = LastTransform?.DeepClone(), + ["packetsReceived"] = PacketsReceived, + ["packetsSent"] = PacketsSent, + ["packetsBroadcast"] = PacketsBroadcast, + ["gameplayActive"] = GameplayActive, + ["protocolVersion"] = ProtocolVersion + }; + } + } +} + +internal readonly record struct BanEntry(string Ip, string Reason, double BannedAt); + +internal sealed class BanStore +{ + private readonly object _gate = new(); + private readonly string _path; + private Dictionary _bans = new(StringComparer.Ordinal); + + public BanStore(string path) + { + _path = Path.GetFullPath(path); + Load(); + } + + public BanEntry? GetBan(string ip) + { + if (!TryNormalizeIp(ip, out var normalized)) return null; + lock (_gate) return _bans.TryGetValue(normalized, out var entry) ? entry : null; + } + + public IReadOnlyList List() + { + lock (_gate) return _bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).ToArray(); + } + + public BanEntry Ban(string ip, string reason) + { + if (!TryNormalizeIp(ip, out var normalized)) throw new ArgumentException($"Invalid IP address: '{ip}'", nameof(ip)); + var entry = new BanEntry(normalized, (reason ?? string.Empty)[..Math.Min((reason ?? string.Empty).Length, 1024)], JsonHelpers.UnixTime()); + lock (_gate) + { + _bans[normalized] = entry; + SaveLocked(); + } + return entry; + } + + public bool Unban(string ip) + { + if (!TryNormalizeIp(ip, out var normalized)) return false; + lock (_gate) + { + if (!_bans.Remove(normalized)) return false; + SaveLocked(); + return true; + } + } + + private void Load() + { + lock (_gate) + { + if (!File.Exists(_path)) { _bans = new(StringComparer.Ordinal); return; } + JsonObject root; + try + { + root = JsonNode.Parse(File.ReadAllBytes(_path)) as JsonObject ?? throw new InvalidDataException("root is not an object"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + throw new InvalidOperationException($"Could not load ban file {_path}: {ex.Message}", ex); + } + if (root["banned_ips"] is not JsonArray array) throw new InvalidOperationException($"Ban file {_path} has an invalid schema."); + var bans = new Dictionary(StringComparer.Ordinal); + var invalid = 0; + foreach (var node in array) + { + if (node is not JsonObject item || !TryNormalizeIp(JsonHelpers.String(item["ip"]) ?? string.Empty, out var ip)) { invalid++; continue; } + var reason = JsonHelpers.String(item["reason"]) ?? string.Empty; + if (reason.Length > 1024) reason = reason[..1024]; + var bannedAt = JsonHelpers.TryDouble(item["bannedAt"] ?? item["banned_at"], double.MinValue, double.MaxValue, out var value) ? value : 0.0; + bans[ip] = new BanEntry(ip, reason, bannedAt); + } + if (invalid > 0) throw new InvalidOperationException($"Ban file {_path} contains {invalid} invalid {(invalid == 1 ? "entry" : "entries")}."); + _bans = bans; + } + } + + private void SaveLocked() + { + var directory = Path.GetDirectoryName(_path)!; + Directory.CreateDirectory(directory); + var root = new JsonObject + { + ["banned_ips"] = new JsonArray(_bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).Select(entry => (JsonNode)new JsonObject + { + ["ip"] = entry.Ip, + ["reason"] = entry.Reason, + ["bannedAt"] = entry.BannedAt + }).ToArray()) + }; + var temp = Path.Combine(directory, $".{Path.GetFileName(_path)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true }); + stream.WriteByte((byte)'\n'); + stream.Flush(true); + } + TryRestrictPermissions(temp); + File.Move(temp, _path, true); + TryRestrictPermissions(_path); + } + + private static bool TryNormalizeIp(string value, out string normalized) + { + normalized = string.Empty; + if (!IPAddress.TryParse(value.Trim(), out var address)) return false; + normalized = address.ToString(); + return true; + } + + internal static void TryRestrictPermissions(string path) + { + if (OperatingSystem.IsWindows()) return; + try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { } + } +} + +internal static class MonotonicClock +{ + private static readonly double Frequency = System.Diagnostics.Stopwatch.Frequency; + public static double Now => System.Diagnostics.Stopwatch.GetTimestamp() / Frequency; +} + +internal static class WorldStatePresets +{ + public static readonly IReadOnlyList<(string Label, string Value)> Weather = new (string, string)[] + { + ("Clear", "0002b52a"), ("Cloudy", "001cc186"), ("Overcast", "001c8556"), ("Fog", "001c3473"), + ("Rain", "001ca7e4"), ("Radstorm", "001c3d5e"), ("Glowing Sea", "000f1033") + }; + + public static readonly IReadOnlyList<(string Label, string Value)> Time = new (string, string)[] + { + ("Midnight", "0000"), ("Dawn", "0600"), ("Morning", "0900"), ("Noon", "1200"), + ("Afternoon", "1500"), ("Evening", "1800"), ("Dusk (7 PM)", "1900"), ("Night", "2200") + }; + + public static string NormalizeWeatherConsoleArg(string value) + { + var text = value.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text[2..]; + if (!uint.TryParse(text, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + throw new ArgumentException("invalid weather form id", nameof(value)); + return parsed.ToString("x8", System.Globalization.CultureInfo.InvariantCulture); + } + + public static string RelayWeatherFormId(string value) => uint.Parse(value, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture).ToString("X8", System.Globalization.CultureInfo.InvariantCulture); + + public static double? HhmmToGameHour(string hhmm) + { + var text = hhmm.Trim(); + if (text.Length is < 1 or > 4 || !text.All(char.IsDigit)) return null; + text = text.PadLeft(4, '0'); + var hours = int.Parse(text[..2]); + var minutes = int.Parse(text[2..]); + return hours > 23 || minutes > 59 ? null : hours + minutes / 60.0; + } +} diff --git a/server/GnsTransport.cs b/server/GnsTransport.cs new file mode 100644 index 0000000..7ae2941 --- /dev/null +++ b/server/GnsTransport.cs @@ -0,0 +1,320 @@ +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal enum GnsEventType : uint { None = 0, Connected = 1, Disconnected = 2, Message = 3, OversizeMessage = 4 } + +internal sealed unsafe class GnsNativeServer : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct NativeEvent + { + public uint Type; + public uint ConnectionId; + public int Reason; + public uint PayloadSize; + public fixed byte Debug[128]; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int CreateDelegate([MarshalAs(UnmanagedType.LPUTF8Str)] string bindHost, ushort port, out IntPtr handle, IntPtr errorBuffer, nuint errorBufferSize); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void DestroyDelegate(IntPtr handle); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate ushort LocalPortDelegate(IntPtr handle); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate uint ConnectionCountDelegate(IntPtr handle); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int PollDelegate(IntPtr handle, NativeEvent* outEvent, IntPtr payloadBuffer, uint payloadCapacity); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int SendDelegate(IntPtr handle, uint connectionId, IntPtr payload, uint payloadSize, uint delivery); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int DisconnectDelegate(IntPtr handle, uint connectionId, int reason, [MarshalAs(UnmanagedType.LPUTF8Str)] string debug); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int RemoteIpv4Delegate(IntPtr handle, uint connectionId, out uint ipv4HostOrder, out ushort port); + + private readonly IntPtr _library; + private IntPtr _handle; + private readonly DestroyDelegate _destroy; + private readonly LocalPortDelegate _localPort; + private readonly ConnectionCountDelegate _connectionCount; + private readonly PollDelegate _poll; + private readonly SendDelegate _send; + private readonly DisconnectDelegate _disconnect; + private readonly RemoteIpv4Delegate _remoteIpv4; + private readonly IntPtr _payloadBuffer = Marshal.AllocHGlobal(ProtocolConstants.MaxMessageBytes); + private int _disposed; + + public GnsNativeServer(string bindHost, int port, string? configuredPath, string serverBaseDirectory) + { + var libraryPath = ResolveLibrary(configuredPath, serverBaseDirectory); + _library = NativeLibrary.Load(libraryPath); + var create = Get("co_gns_server_create"); + _destroy = Get("co_gns_server_destroy"); + _localPort = Get("co_gns_server_local_port"); + _connectionCount = Get("co_gns_server_connection_count"); + _poll = Get("co_gns_server_poll"); + _send = Get("co_gns_server_send"); + _disconnect = Get("co_gns_server_disconnect"); + _remoteIpv4 = Get("co_gns_server_remote_ipv4"); + + var errorBuffer = Marshal.AllocHGlobal(512); + try + { + new Span((void*)errorBuffer, 512).Clear(); + var result = create(bindHost, checked((ushort)port), out _handle, errorBuffer, 512); + if (result != 1 || _handle == IntPtr.Zero) + throw new InvalidOperationException(Marshal.PtrToStringUTF8(errorBuffer) ?? "GNS native bridge failed to start"); + } + finally { Marshal.FreeHGlobal(errorBuffer); } + } + + public ushort LocalPort => _localPort(_handle); + public uint ConnectionCount => _connectionCount(_handle); + + public (GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? Poll() + { + NativeEvent native = default; + var result = _poll(_handle, &native, _payloadBuffer, ProtocolConstants.MaxMessageBytes); + if (result == 0) return null; + if (result < 0) throw new IOException($"GNS native poll failed with result {result}"); + if (!Enum.IsDefined(typeof(GnsEventType), native.Type)) throw new IOException($"GNS native bridge returned unknown event type {native.Type}"); + var type = (GnsEventType)native.Type; + if (native.PayloadSize > ProtocolConstants.MaxMessageBytes && type != GnsEventType.OversizeMessage) throw new IOException("GNS native bridge returned an oversized message payload"); + var payload = Array.Empty(); + if (type == GnsEventType.Message && native.PayloadSize > 0) + { + payload = new byte[native.PayloadSize]; + Marshal.Copy(_payloadBuffer, payload, 0, payload.Length); + } + string debug; + fixed (byte* pointer = native.Debug) + { + var length = 0; + while (length < 128 && pointer[length] != 0) length++; + debug = Encoding.UTF8.GetString(pointer, length); + } + return (type, native.ConnectionId, native.Reason, payload, debug); + } + + public SendOutcome Send(uint connectionId, ReadOnlySpan payload, Delivery delivery) + { + if (payload.Length > ProtocolConstants.MaxMessageBytes) return SendOutcome.TooLarge; + fixed (byte* pointer = payload) + { + return _send(_handle, connectionId, (IntPtr)pointer, (uint)payload.Length, (uint)delivery) switch + { + 0 => SendOutcome.Sent, + 1 => SendOutcome.Dropped, + 2 => SendOutcome.Backpressure, + 3 => SendOutcome.NotConnected, + 4 => SendOutcome.TooLarge, + _ => SendOutcome.Error + }; + } + } + + public void Disconnect(uint connectionId, int reason, string debug) => _disconnect(_handle, connectionId, reason, debug); + + public IPEndPoint? RemoteEndpoint(uint connectionId) + { + if (_remoteIpv4(_handle, connectionId, out var ipv4, out var port) != 1) return null; + var bytes = new[] { (byte)(ipv4 >> 24), (byte)(ipv4 >> 16), (byte)(ipv4 >> 8), (byte)ipv4 }; + return new IPEndPoint(new IPAddress(bytes), port); + } + + private T Get(string name) where T : Delegate => Marshal.GetDelegateForFunctionPointer(NativeLibrary.GetExport(_library, name)); + + private static string ResolveLibrary(string? configuredPath, string serverBaseDirectory) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + var full = Path.GetFullPath(configuredPath, serverBaseDirectory); + if (File.Exists(full)) return full; + throw new FileNotFoundException("Configured GNS bridge was not found", full); + } + var name = OperatingSystem.IsWindows() ? "commonwealth_online_gns_bridge.dll" : OperatingSystem.IsMacOS() ? "libcommonwealth_online_gns_bridge.dylib" : "libcommonwealth_online_gns_bridge.so"; + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, name), + Path.Combine(AppContext.BaseDirectory, "native_transport", name), + Path.Combine(serverBaseDirectory, name), + Path.Combine(serverBaseDirectory, "native_transport", name), + Path.Combine(Environment.CurrentDirectory, "native_transport", name) + }; + return candidates.FirstOrDefault(File.Exists) ?? throw new FileNotFoundException($"Commonwealth Online GNS native bridge was not found. Searched: {string.Join(", ", candidates)}"); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + if (_handle != IntPtr.Zero) { _destroy(_handle); _handle = IntPtr.Zero; } + Marshal.FreeHGlobal(_payloadBuffer); + if (_library != IntPtr.Zero) NativeLibrary.Free(_library); + } +} + +internal sealed class GnsGameConnection : IGameConnection +{ + private readonly GnsNativeServer _native; + private readonly uint _id; + private readonly object _gate = new(); + private readonly Dictionary _outgoingSequences = new(StringComparer.Ordinal) + { + ["transform"] = new SequenceCounter(), ["npcState"] = new SequenceCounter() + }; + private int _closed; + + public GnsGameConnection(GnsNativeServer native, uint id, IPEndPoint remoteEndpoint) { _native = native; _id = id; RemoteEndpoint = remoteEndpoint; ConnectionKey = $"gns:{id}"; } + public string ConnectionKey { get; } + public string TransportName => "gns"; + public IPEndPoint RemoteEndpoint { get; } + public bool IsClosed => Volatile.Read(ref _closed) != 0; + public uint NativeId => _id; + + public ValueTask SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default) + { + if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected); + lock (_gate) + { + if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected); + ReadOnlySpan wire = packet.Payload; + byte[]? envelope = null; + if (TransportPolicy.IsSnapshot(packet.PacketType)) + { + envelope = GnsSnapshotEnvelope.Encode(packet.PacketType, packet.Payload, _outgoingSequences[packet.PacketType].Advance()); + wire = envelope; + } + return ValueTask.FromResult(_native.Send(_id, wire, packet.Delivery)); + } + } + + public ValueTask DisconnectAsync(int reason, string debug) + { + if (Interlocked.Exchange(ref _closed, 1) == 0) { try { _native.Disconnect(_id, reason, debug); } catch { } } + return ValueTask.CompletedTask; + } + internal void MarkRemoteClosed() => Interlocked.Exchange(ref _closed, 1); + public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose"); +} + +internal sealed class GnsServerTransport : IAsyncDisposable +{ + private readonly ServerOptions _options; + private readonly IServerIngress _server; + private readonly CancellationTokenSource _shutdown = new(); + private readonly Dictionary _connections = new(); + private readonly Dictionary<(uint ConnectionId, string PacketType), SequenceWindow> _incomingSequences = new(); + private readonly object _gate = new(); + private GnsNativeServer? _native; + private Task? _pumpTask; + + public GnsServerTransport(ServerOptions options, IServerIngress server) { _options = options; _server = server; } + + public void Start() + { + if (_pumpTask is not null) return; + _native = new GnsNativeServer(_options.Host, _options.Port, _options.GnsBridgePath, _options.BaseDirectory); + if (_native.LocalPort != _options.Port) throw new InvalidOperationException($"GNS transport bound UDP {_native.LocalPort}, expected UDP {_options.Port}."); + _pumpTask = Task.Run(() => PumpAsync(_shutdown.Token)); + _server.Log($"GameNetworkingSockets gameplay transport listening on UDP {_options.Port}."); + } + + private async Task PumpAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + (GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? evt; + try { evt = _native!.Poll(); } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) _server.Log($"GNS poll error: {ex.Message}", "error"); + try { await Task.Delay(10, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } + continue; + } + if (evt is null) + { + try { await Task.Delay(2, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } + continue; + } + var value = evt.Value; + switch (value.Type) + { + case GnsEventType.Connected: await HandleConnectedAsync(value.ConnectionId, cancellationToken).ConfigureAwait(false); break; + case GnsEventType.Message: await HandleMessageAsync(value.ConnectionId, value.Payload, cancellationToken).ConfigureAwait(false); break; + case GnsEventType.OversizeMessage: + if (TryGetConnection(value.ConnectionId, out var oversized)) await _server.EndSessionForTransportAsync(oversized, "packet_too_large", "Packet exceeded maximum message size.").ConfigureAwait(false); + else _native!.Disconnect(value.ConnectionId, 0, "Oversized pre-session packet"); + break; + case GnsEventType.Disconnected: await HandleDisconnectedAsync(value.ConnectionId).ConfigureAwait(false); break; + } + } + } + + private async Task HandleConnectedAsync(uint id, CancellationToken cancellationToken) + { + var endpoint = _native!.RemoteEndpoint(id); + if (endpoint is null) { _native.Disconnect(id, 0, "Remote endpoint unavailable"); return; } + var connection = new GnsGameConnection(_native, id, endpoint); + lock (_gate) _connections[id] = connection; + bool accepted; + try { accepted = await _server.AcceptConnectionAsync(connection, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) { _server.Log($"GNS admission failed for {endpoint}: {ex.Message}", "warning"); accepted = false; } + if (!accepted) { lock (_gate) _connections.Remove(id); await connection.DisposeAsync(); } + } + + private async Task HandleMessageAsync(uint id, byte[] payload, CancellationToken cancellationToken) + { + if (!TryGetConnection(id, out var connection)) { _native!.Disconnect(id, 0, "Message before GNS admission"); return; } + ReadOnlyMemory gameplayPayload = payload; + if (GnsSnapshotEnvelope.TryDecode(payload, out var envelope, out var envelopeError)) + { + if (envelopeError is not null) { await _server.HandleTransportRejectAsync(connection, $"Malformed GNS snapshot envelope: {envelopeError}").ConfigureAwait(false); return; } + bool accepted; + lock (_gate) + { + if (!_incomingSequences.TryGetValue((id, envelope.PacketType), out var window)) _incomingSequences[(id, envelope.PacketType)] = window = new SequenceWindow(); + accepted = window.Accept(envelope.Sequence); + } + if (!accepted) { await _server.HandleTransportRejectAsync(connection, "Stale or duplicate GNS snapshot sequence", false).ConfigureAwait(false); return; } + JsonObject packet; + try { packet = PacketCodec.Decode(envelope.Payload); } + catch (PacketCodecException ex) { await _server.HandleTransportRejectAsync(connection, $"Invalid GNS snapshot payload: {ex.Message}").ConfigureAwait(false); return; } + if (JsonHelpers.String(packet["type"]) != envelope.PacketType) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot envelope family does not match packet type").ConfigureAwait(false); return; } + gameplayPayload = envelope.Payload; + } + else + { + try + { + var packet = PacketCodec.Decode(payload); + var type = JsonHelpers.String(packet["type"])!; + if (TransportPolicy.IsSnapshot(type)) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot missing required sequence envelope").ConfigureAwait(false); return; } + } + catch (PacketCodecException) { } + } + await _server.HandleMessageAsync(connection, gameplayPayload, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleDisconnectedAsync(uint id) + { + GnsGameConnection? connection; + lock (_gate) + { + _connections.Remove(id, out connection); + foreach (var key in _incomingSequences.Keys.Where(x => x.ConnectionId == id).ToArray()) _incomingSequences.Remove(key); + } + if (connection is null) return; + connection.MarkRemoteClosed(); + await _server.HandleConnectionClosedAsync(connection).ConfigureAwait(false); + } + + private bool TryGetConnection(uint id, out GnsGameConnection connection) { lock (_gate) return _connections.TryGetValue(id, out connection!); } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + if (_pumpTask is not null) { try { await _pumpTask.ConfigureAwait(false); } catch { } } + GnsGameConnection[] connections; + lock (_gate) { connections = _connections.Values.ToArray(); _connections.Clear(); _incomingSequences.Clear(); } + foreach (var connection in connections) await connection.DisposeAsync(); + _native?.Dispose(); + _native = null; + _shutdown.Dispose(); + } +} diff --git a/server/Program.cs b/server/Program.cs new file mode 100644 index 0000000..3403345 --- /dev/null +++ b/server/Program.cs @@ -0,0 +1,337 @@ +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"); + } +} diff --git a/server/ProtocolCore.cs b/server/ProtocolCore.cs new file mode 100644 index 0000000..f908b03 --- /dev/null +++ b/server/ProtocolCore.cs @@ -0,0 +1,176 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal static class ProtocolConstants +{ + public const int ProtocolVersion = 2; + public const int LegacyProtocolVersion = 1; + public const int MaxMessageBytes = 64 * 1024; + public const double MaxAbsCoordinate = 10_000_000.0; + public const double MaxMovementSpeed = 100_000.0; + public const int MaxActionEvents = 16; + public const int MaxNpcsPerPacket = 64; + public const double ExteriorInterestRadius = 8192.0; + public const double MaxNormalMovementSpeed = 2500.0; + public const double MovementGraceDistance = 512.0; + public const double MaxMovementValidationElapsedSeconds = 5.0; + + public static readonly HashSet MovementTransitionTypes = new(StringComparer.Ordinal) + { + "teleport", "cell_change", "worldspace_change", "load", "spawn", "fast_travel" + }; + + public static readonly HashSet AllowedMovementTypes = new(MovementTransitionTypes, StringComparer.Ordinal) { "normal" }; +} + +internal enum Delivery : uint { UnreliableSequenced = 0, ReliableOrdered = 1 } +internal readonly record struct EncodedPacket(string PacketType, byte[] Payload, Delivery Delivery); + +internal static class TransportPolicy +{ + public static Delivery ForPacketType(string packetType) => packetType is "transform" or "npcState" ? Delivery.UnreliableSequenced : Delivery.ReliableOrdered; + public static bool IsSnapshot(string packetType) => ForPacketType(packetType) == Delivery.UnreliableSequenced; +} + +internal sealed class PacketCodecException(string message) : Exception(message); + +internal static class PacketCodec +{ + private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false }; + + public static EncodedPacket Encode(JsonObject packet) + { + var type = JsonHelpers.String(packet["type"]); + if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string"); + byte[] payload; + try { payload = JsonSerializer.SerializeToUtf8Bytes(packet, Compact); } + catch (Exception ex) when (ex is JsonException or NotSupportedException) { throw new PacketCodecException($"packet is not JSON serializable: {ex.Message}"); } + if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size"); + return new EncodedPacket(type, payload, TransportPolicy.ForPacketType(type)); + } + + public static JsonObject Decode(ReadOnlySpan payload) + { + if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size"); + JsonNode? node; + try + { + node = JsonNode.Parse(payload, documentOptions: new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow }); + } + catch (JsonException ex) { throw new PacketCodecException($"invalid JSON packet: {ex.Message}"); } + if (node is not JsonObject packet) throw new PacketCodecException("packet must be a JSON object"); + var type = JsonHelpers.String(packet["type"]); + if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string"); + return packet; + } +} + +internal static class JsonHelpers +{ + public static string? String(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : null; + public static bool? Boolean(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var result) ? result : null; + + public static bool TryUInt32(JsonNode? node, uint min, uint max, out uint result) + { + result = 0; + if (node is not JsonValue value || value.TryGetValue(out _)) return false; + if (value.TryGetValue(out var u) && u >= min && u <= max) { result = u; return true; } + if (value.TryGetValue(out var l) && l >= min && l <= max) { result = (uint)l; return true; } + if (value.TryGetValue(out var d) && double.IsFinite(d) && d == Math.Truncate(d) && d >= min && d <= max) { result = (uint)d; return true; } + return false; + } + + public static bool TryDouble(JsonNode? node, double min, double max, out double result) + { + result = 0; + if (node is not JsonValue value || value.TryGetValue(out _)) return false; + double d; + if (value.TryGetValue(out var direct)) d = direct; + else if (value.TryGetValue(out var l)) d = l; + else if (value.TryGetValue(out var m)) d = (double)m; + else return false; + if (!double.IsFinite(d) || d < min || d > max) return false; + result = d; + return true; + } + + public static bool IsHexFormId(JsonNode? node, bool allowEmpty = false, bool allowZero = true) => String(node) is { } text && IsHexFormId(text, allowEmpty, allowZero); + public static bool IsHexFormId(string text, bool allowEmpty = false, bool allowZero = true) + { + if (allowEmpty && text.Length == 0) return true; + if (text.Length is < 1 or > 8) return false; + if (!uint.TryParse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) return false; + return allowZero || parsed != 0; + } + public static string NormalizeFormId(string text) => uint.Parse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture).ToString("X8", CultureInfo.InvariantCulture); + public static JsonObject CloneObject(JsonObject source) => (JsonObject)source.DeepClone(); + public static double UnixTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0; +} + +internal sealed class SequenceCounter +{ + private uint _value; + public uint Current => _value; + public uint Advance() { unchecked { _value++; } if (_value == 0) _value = 1; return _value; } + public void Reset() => _value = 0; +} + +internal sealed class SequenceWindow +{ + private uint _lastAccepted; + public uint LastAccepted => _lastAccepted; + public bool Accept(uint candidate) { if (!IsNewer(candidate, _lastAccepted)) return false; _lastAccepted = candidate; return true; } + public void Reset() => _lastAccepted = 0; + public static bool IsNewer(uint candidate, uint baseline) + { + if (candidate == 0) return false; + if (baseline == 0) return true; + var delta = unchecked(candidate - baseline); + return delta > 0 && delta < 0x80000000u; + } +} + +internal readonly record struct SnapshotEnvelope(string PacketType, uint Sequence, byte[] Payload); + +internal static class GnsSnapshotEnvelope +{ + private static ReadOnlySpan Magic => "COG2"u8; + private const byte Version = 1; + public const int HeaderSize = 12; + + public static byte[] Encode(string packetType, ReadOnlySpan payload, uint sequence) + { + var family = packetType switch { "transform" => (byte)1, "npcState" => (byte)2, _ => throw new ArgumentException($"packet type '{packetType}' is not a GNS snapshot family", nameof(packetType)) }; + if (sequence == 0) throw new ArgumentOutOfRangeException(nameof(sequence)); + if (payload.IsEmpty) throw new ArgumentException("snapshot payload cannot be empty", nameof(payload)); + if (HeaderSize + payload.Length > ProtocolConstants.MaxMessageBytes) throw new ArgumentException("snapshot envelope exceeds maximum GNS message size"); + var output = new byte[HeaderSize + payload.Length]; + Magic.CopyTo(output); + output[4] = Version; + output[5] = family; + BinaryPrimitives.WriteUInt16BigEndian(output.AsSpan(6, 2), 0); + BinaryPrimitives.WriteUInt32BigEndian(output.AsSpan(8, 4), sequence); + payload.CopyTo(output.AsSpan(HeaderSize)); + return output; + } + + public static bool TryDecode(ReadOnlySpan message, out SnapshotEnvelope envelope, out string? error) + { + envelope = default; error = null; + if (message.Length < 4 || !message[..4].SequenceEqual(Magic)) return false; + if (message.Length < HeaderSize) { error = "truncated GNS snapshot envelope"; return true; } + if (message[4] != Version || BinaryPrimitives.ReadUInt16BigEndian(message.Slice(6, 2)) != 0) { error = "invalid GNS snapshot envelope header"; return true; } + var type = message[5] switch { 1 => "transform", 2 => "npcState", _ => null }; + if (type is null) { error = "unknown GNS snapshot family"; return true; } + var sequence = BinaryPrimitives.ReadUInt32BigEndian(message.Slice(8, 4)); + if (sequence == 0) { error = "snapshot sequence zero is reserved"; return true; } + var payload = message[HeaderSize..].ToArray(); + if (payload.Length == 0) { error = "snapshot envelope payload is empty"; return true; } + envelope = new SnapshotEnvelope(type, sequence, payload); + return true; + } +} diff --git a/server/ProtocolValidation.cs b/server/ProtocolValidation.cs new file mode 100644 index 0000000..ef7b1d6 --- /dev/null +++ b/server/ProtocolValidation.cs @@ -0,0 +1,341 @@ +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal static class ProtocolValidation +{ + private const int MaxCharacterNameChars = 128; + private const int MaxEquippedItems = 32; + private const int MaxEquipmentSlotChars = 64; + private const int MaxHeadParts = 64; + private const int MaxMorphs = 128; + private const int MaxMorphRegions = 128; + private const int MaxFacialBoneMorphs = 128; + private const int MaxTints = 128; + + public static JsonArray NormalizeActionEvents(JsonNode? value, bool strict) + { + var output = new JsonArray(); + if (value is not JsonArray input) return output; + var count = Math.Min(input.Count, ProtocolConstants.MaxActionEvents); + for (var i = 0; i < count; i++) + { + if (input[i] is not JsonObject item) + { + if (strict) return new JsonArray(); + continue; + } + if (!JsonHelpers.TryUInt32(item["sequence"], 1, uint.MaxValue, out var sequence) || + !JsonHelpers.TryUInt32(item["type"], 1, 3, out var actionType) || + JsonHelpers.String(item["eventName"]) is not { } eventName || + !((actionType is 1 or 2 && eventName == "meleeattackStart") || (actionType == 3 && eventName == "fireSingle"))) + { + if (strict) return new JsonArray(); + continue; + } + var clean = new JsonObject { ["sequence"] = sequence, ["type"] = actionType, ["eventName"] = eventName }; + foreach (var name in new[] { "actorStateFlags1", "actorStateFlags2" }) + if (JsonHelpers.TryUInt32(item[name], 0, uint.MaxValue, out var flags)) clean[name] = flags; + output.Add(clean); + } + return output; + } + + public static JsonObject? NormalizePlayerState(JsonObject packet) + { + var clean = new JsonObject { ["type"] = "playerState" }; + var hasState = false; + if (packet.ContainsKey("equippedItems")) + { + if (packet["equippedItems"] is not JsonArray items || items.Count > MaxEquippedItems) return null; + var cleanItems = new JsonArray(); + foreach (var node in items) + { + if (node is not JsonObject item) return null; + var slot = JsonHelpers.String(item["slot"]); + var form = JsonHelpers.String(item["formId"]) ?? string.Empty; + if (slot is null || slot.Length is < 1 or > MaxEquipmentSlotChars || !JsonHelpers.IsHexFormId(form, true, true)) return null; + cleanItems.Add(new JsonObject { ["slot"] = slot, ["formId"] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form) }); + } + clean["equippedItems"] = cleanItems; + hasState = true; + } + if (packet.ContainsKey("appearance")) + { + if (packet["appearance"] is not JsonObject appearance) return null; + var normalizedAppearance = NormalizeAppearance(appearance); + if (normalizedAppearance is null) return null; + clean["appearance"] = normalizedAppearance; + hasState = true; + } + if (packet.ContainsKey("actionEvents")) + { + if (packet["actionEvents"] is not JsonArray actions) return null; + var normalized = NormalizeActionEvents(actions, true); + if (normalized.Count != actions.Count) return null; + clean["actionEvents"] = normalized; + hasState = true; + } + if (packet.ContainsKey("characterName")) + { + var name = JsonHelpers.String(packet["characterName"]); + if (name is null || name.Length > MaxCharacterNameChars) return null; + clean["characterName"] = name; + hasState = true; + } + return hasState ? clean : null; + } + + private static JsonObject? NormalizeAppearance(JsonObject value) + { + var clean = new JsonObject(); + uint version = 4; + if (value.ContainsKey("version") && !JsonHelpers.TryUInt32(value["version"], 1, 1000, out version)) return null; + clean["version"] = version; + foreach (var name in new[] { "raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId" }) + { + if (!value.ContainsKey(name)) continue; + var form = JsonHelpers.String(value[name]); + if (form is null || !JsonHelpers.IsHexFormId(form, true, true)) return null; + clean[name] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form); + } + if (value.ContainsKey("height")) + { + if (!JsonHelpers.TryDouble(value["height"], 0.25, 4.0, out var height)) return null; + clean["height"] = height; + } + if (value.ContainsKey("isFemale")) + { + var female = JsonHelpers.Boolean(value["isFemale"]); + if (female is null) return null; + clean["isFemale"] = female.Value; + } + if (value.ContainsKey("morphWeight")) + { + if (value["morphWeight"] is not JsonObject weights) return null; + var normalized = new JsonObject(); + foreach (var name in new[] { "thin", "muscular", "large" }) + { + if (!JsonHelpers.TryDouble(weights[name], -100, 100, out var weight)) return null; + normalized[name] = weight; + } + clean["morphWeight"] = normalized; + } + if (value.ContainsKey("bodyTintColor")) + { + if (value["bodyTintColor"] is not JsonObject color) return null; + var normalized = new JsonObject(); + foreach (var name in new[] { "r", "g", "b", "a" }) + { + if (!JsonHelpers.TryUInt32(color[name], 0, 255, out var component)) return null; + normalized[name] = component; + } + clean["bodyTintColor"] = normalized; + } + if (value.ContainsKey("headParts")) + { + if (value["headParts"] is not JsonArray parts || parts.Count > MaxHeadParts) return null; + var normalized = new JsonArray(); + foreach (var node in parts) + { + var form = JsonHelpers.String(node); + if (form is null || !JsonHelpers.IsHexFormId(form, false, true)) return null; + normalized.Add(JsonHelpers.NormalizeFormId(form)); + } + clean["headParts"] = normalized; + } + if (value.ContainsKey("morphs")) + { + if (value["morphs"] is not JsonArray morphs || morphs.Count > MaxMorphs) return null; + var normalized = new JsonArray(); + foreach (var node in morphs) + { + if (node is not JsonObject morph) return null; + var id = JsonHelpers.String(morph["id"]); + if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || !JsonHelpers.TryDouble(morph["value"], -1000, 1000, out var amount)) return null; + normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["value"] = amount }); + } + clean["morphs"] = normalized; + } + if (value.ContainsKey("morphRegions")) + { + if (value["morphRegions"] is not JsonArray regions || regions.Count > MaxMorphRegions) return null; + var normalized = new JsonArray(); + foreach (var node in regions) + { + if (!JsonHelpers.TryDouble(node, -1000, 1000, out var amount)) return null; + normalized.Add(amount); + } + clean["morphRegions"] = normalized; + } + if (value.ContainsKey("facialBoneMorphs")) + { + if (value["facialBoneMorphs"] is not JsonArray morphs || morphs.Count > MaxFacialBoneMorphs) return null; + var normalized = new JsonArray(); + foreach (var node in morphs) + { + if (node is not JsonObject morph) return null; + var id = JsonHelpers.String(morph["id"]); + var position = NormalizeVec3(morph["position"], -10000, 10000); + var rotation = NormalizeVec3(morph["rotation"], -10000, 10000); + var scale = NormalizeVec3(morph["scale"], -100, 100); + if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || position is null || rotation is null || scale is null) return null; + normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["position"] = position, ["rotation"] = rotation, ["scale"] = scale }); + } + clean["facialBoneMorphs"] = normalized; + } + if (value.ContainsKey("tints")) + { + if (value["tints"] is not JsonArray tints || tints.Count > MaxTints) return null; + var normalized = new JsonArray(); + foreach (var node in tints) + { + if (node is not JsonObject tint || !JsonHelpers.TryUInt32(tint["id"], 0, ushort.MaxValue, out var id) || + !JsonHelpers.TryUInt32(tint["type"], 0, uint.MaxValue, out var type) || !JsonHelpers.TryUInt32(tint["value"], 0, 255, out var amount)) return null; + var cleanTint = new JsonObject { ["id"] = id, ["type"] = type, ["value"] = amount }; + if (tint.ContainsKey("color")) + { + var color = JsonHelpers.String(tint["color"]); + var swatchNode = tint["swatch"] ?? JsonValue.Create(0); + if (color is null || !JsonHelpers.IsHexFormId(color, false, true) || !JsonHelpers.TryUInt32(swatchNode, 0, ushort.MaxValue, out var swatch)) return null; + cleanTint["color"] = JsonHelpers.NormalizeFormId(color); + cleanTint["swatch"] = swatch; + } + normalized.Add(cleanTint); + } + clean["tints"] = normalized; + } + return clean; + } + + private static JsonArray? NormalizeVec3(JsonNode? node, double min, double max) + { + if (node is not JsonArray array || array.Count != 3) return null; + var output = new JsonArray(); + foreach (var component in array) + { + if (!JsonHelpers.TryDouble(component, min, max, out var value)) return null; + output.Add(value); + } + return output; + } + + public static JsonObject? NormalizeTransform(JsonObject packet) + { + foreach (var field in new[] { "x", "y", "z", "angleZ" }) + if (!JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null; + var cell = JsonHelpers.String(packet["cellId"]); + var world = JsonHelpers.String(packet["worldspaceId"]) ?? string.Empty; + if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null; + var movementType = JsonHelpers.String(packet["movementType"]) ?? "normal"; + if (!ProtocolConstants.AllowedMovementTypes.Contains(movementType)) return null; + var normalized = JsonHelpers.CloneObject(packet); + foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); normalized[field] = v; } + normalized["cellId"] = JsonHelpers.NormalizeFormId(cell); + normalized["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); + normalized["movementType"] = movementType; + foreach (var field in new[] { "movementSpeed", "animationGraphSpeed" }) + { + if (!packet.ContainsKey(field)) continue; + var min = field == "animationGraphSpeed" ? -1.0 : 0.0; + if (JsonHelpers.TryDouble(packet[field], min, ProtocolConstants.MaxMovementSpeed, out var value)) normalized[field] = value; else normalized.Remove(field); + } + foreach (var (field, min, max) in new[] { ("animationDirection", -360.0, 360.0), ("aimPitch", -180.0, 180.0), ("turnDelta", -10000.0, 10000.0) }) + { + if (!packet.ContainsKey(field)) continue; + if (JsonHelpers.TryDouble(packet[field], min, max, out var value)) normalized[field] = value; else normalized.Remove(field); + } + foreach (var field in new[] { "isMoving", "isSprinting", "isSneaking", "isJumping", "isCrouching", "weaponDrawn" }) + if (packet.ContainsKey(field) && JsonHelpers.Boolean(packet[field]) is null) normalized.Remove(field); + foreach (var field in new[] { "actorStateFlags1", "actorStateFlags2" }) + if (packet.ContainsKey(field) && !JsonHelpers.TryUInt32(packet[field], 0, uint.MaxValue, out _)) normalized.Remove(field); + if (packet.ContainsKey("actionEvents")) normalized["actionEvents"] = NormalizeActionEvents(packet["actionEvents"], false); + return normalized; + } + + public static (bool Accepted, string Reason) ValidateMovement(JsonObject? previous, double? previousMonotonic, JsonObject current, double nowMonotonic) + { + if (previous is null || previousMonotonic is null) return (true, "first transform"); + var movementType = JsonHelpers.String(current["movementType"]) ?? "normal"; + if (ProtocolConstants.MovementTransitionTypes.Contains(movementType)) return (true, $"explicit {movementType} transition"); + if ((JsonHelpers.String(previous["cellId"]) ?? string.Empty) != (JsonHelpers.String(current["cellId"]) ?? string.Empty) || + (JsonHelpers.String(previous["worldspaceId"]) ?? string.Empty) != (JsonHelpers.String(current["worldspaceId"]) ?? string.Empty)) + return (false, "scope changed without an explicit movement transition"); + var elapsed = nowMonotonic - previousMonotonic.Value; + if (!double.IsFinite(elapsed) || elapsed < 0) elapsed = 0; + elapsed = Math.Min(elapsed, ProtocolConstants.MaxMovementValidationElapsedSeconds); + JsonHelpers.TryDouble(current["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x); + JsonHelpers.TryDouble(current["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y); + JsonHelpers.TryDouble(current["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var z); + JsonHelpers.TryDouble(previous["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var px); + JsonHelpers.TryDouble(previous["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var py); + JsonHelpers.TryDouble(previous["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var pz); + var dx = x - px; var dy = y - py; var dz = z - pz; + var distance = Math.Sqrt(dx * dx + dy * dy + dz * dz); + var allowed = ProtocolConstants.MovementGraceDistance + ProtocolConstants.MaxNormalMovementSpeed * elapsed; + return double.IsFinite(distance) && distance <= allowed ? (true, "normal movement accepted") : (false, $"normal movement exceeded server envelope: distance={distance:F1}, allowed={allowed:F1}, elapsed={elapsed:F3}s"); + } + + public static JsonObject? NormalizeWorldState(JsonObject packet) + { + var normalized = JsonHelpers.CloneObject(packet); + if (packet.ContainsKey("gameHour")) { if (!JsonHelpers.TryDouble(packet["gameHour"], 0, 24, out var hour)) return null; normalized["gameHour"] = hour; } + if (packet.ContainsKey("gameDaysPassed")) { if (!JsonHelpers.TryDouble(packet["gameDaysPassed"], 0, 10_000_000, out var days)) return null; normalized["gameDaysPassed"] = days; } + if (packet.ContainsKey("weatherFormId")) + { + var weather = JsonHelpers.String(packet["weatherFormId"]); + if (weather is null || !JsonHelpers.IsHexFormId(weather, true, true)) return null; + normalized["weatherFormId"] = weather.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(weather); + } + return normalized; + } + + public static JsonObject? NormalizeNpcState(JsonObject packet) + { + if (packet["npcs"] is not JsonArray npcs || npcs.Count > ProtocolConstants.MaxNpcsPerPacket) return null; + var cleanNpcs = new JsonArray(); + foreach (var node in npcs) + { + if (node is not JsonObject npc) return null; + var source = JsonHelpers.String(npc["sourceFormId"]); var cell = JsonHelpers.String(npc["cellId"]); var world = JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty; + if (source is null || cell is null || !JsonHelpers.IsHexFormId(source, false, false) || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null; + foreach (var field in new[] { "x", "y", "z", "angleZ" }) if (!JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null; + var clean = JsonHelpers.CloneObject(npc); + clean["sourceFormId"] = JsonHelpers.NormalizeFormId(source); clean["cellId"] = JsonHelpers.NormalizeFormId(cell); clean["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); + foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); clean[field] = v; } + cleanNpcs.Add(clean); + } + var normalized = JsonHelpers.CloneObject(packet); normalized["npcs"] = cleanNpcs; + if (packet.ContainsKey("authorityEpoch")) { if (!JsonHelpers.TryUInt32(packet["authorityEpoch"], 1, uint.MaxValue, out var epoch)) return null; normalized["authorityEpoch"] = epoch; } + if (packet.ContainsKey("authorityCellId")) { var cell = JsonHelpers.String(packet["authorityCellId"]); if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false)) return null; normalized["authorityCellId"] = JsonHelpers.NormalizeFormId(cell); } + if (packet.ContainsKey("authorityWorldspaceId")) { var world = JsonHelpers.String(packet["authorityWorldspaceId"]); if (world is null || !JsonHelpers.IsHexFormId(world, true, true)) return null; normalized["authorityWorldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); } + return normalized; + } + + public static JsonObject? NormalizeCombatHit(JsonObject packet) + { + if (!JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var target) || !JsonHelpers.TryUInt32(packet["sequence"], 1, uint.MaxValue, out var sequence) || !JsonHelpers.TryDouble(packet["damage"], 0.000001, 10000, out var damage)) return null; + var normalized = JsonHelpers.CloneObject(packet); normalized["targetPlayerId"] = target; normalized["sequence"] = sequence; normalized["damage"] = damage; + if (packet.ContainsKey("weaponFormId")) { var weapon = JsonHelpers.String(packet["weaponFormId"]); if (weapon is null || !JsonHelpers.IsHexFormId(weapon, false, true)) return null; normalized["weaponFormId"] = JsonHelpers.NormalizeFormId(weapon); } + return normalized; + } + + public static StateScope? ScopeFromState(JsonObject? state) + { + if (state is null) return null; + var cell = JsonHelpers.String(state["cellId"]); var world = JsonHelpers.String(state["worldspaceId"]) ?? string.Empty; + if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null; + if (!JsonHelpers.TryDouble(state["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x) || !JsonHelpers.TryDouble(state["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y)) return null; + return new StateScope(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world), x, y); + } + + public static bool StatesShareInterest(JsonObject? a, JsonObject? b) + { + var left = ScopeFromState(a); var right = ScopeFromState(b); + if (left is null || right is null) return true; + if (left.Value.CellId == right.Value.CellId) return true; + if (string.IsNullOrEmpty(left.Value.WorldspaceId) || left.Value.WorldspaceId != right.Value.WorldspaceId) return false; + var dx = left.Value.X - right.Value.X; var dy = left.Value.Y - right.Value.Y; + return Math.Sqrt(dx * dx + dy * dy) <= ProtocolConstants.ExteriorInterestRadius; + } +} diff --git a/server/ServerRuntime.cs b/server/ServerRuntime.cs new file mode 100644 index 0000000..1081479 --- /dev/null +++ b/server/ServerRuntime.cs @@ -0,0 +1,53 @@ +namespace CommonwealthOnline.Server; + +internal sealed class ServerRuntime : IAsyncDisposable +{ + private readonly ServerOptions _options; + private readonly AuthoritativeServer _server; + private readonly TcpServerTransport _tcp; + private readonly GnsServerTransport? _gns; + private readonly AdminControlServer _admin; + private readonly LanDiscoveryService _discovery; + private int _started; + + public ServerRuntime(ServerOptions options) + { + _options = options; + _server = new AuthoritativeServer(options); + _tcp = new TcpServerTransport(options, _server); + _gns = options.EnableGnsTransport ? new GnsServerTransport(options, _server) : null; + _admin = new AdminControlServer(_server, options); + _discovery = new LanDiscoveryService(_server, options); + } + + public AuthoritativeServer Server => _server; + + public void Start() + { + if (Interlocked.Exchange(ref _started, 1) != 0) return; + _admin.Start(); + try + { + _tcp.Start(); + _gns?.Start(); + try { _discovery.Start(); } + catch (Exception ex) { _server.Log($"LAN discovery unavailable on UDP {LanDiscoveryService.DiscoveryPort}: {ex.Message}. Direct connections still work.", "warning"); } + } + catch + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + throw; + } + _server.Log($"Commonwealth Online server listening on {_options.Host}:{_options.Port}"); + _server.Log($"Protocol v{ProtocolConstants.ProtocolVersion} negotiation enabled; legacy TCP clients remain temporarily compatible."); + } + + public async ValueTask DisposeAsync() + { + if (_gns is not null) await _gns.DisposeAsync(); + await _tcp.DisposeAsync(); + await _discovery.DisposeAsync(); + await _admin.DisposeAsync(); + await _server.DisposeAsync(); + } +} diff --git a/server/SyntheticProtocolClient.cs b/server/SyntheticProtocolClient.cs new file mode 100644 index 0000000..a26d406 --- /dev/null +++ b/server/SyntheticProtocolClient.cs @@ -0,0 +1,76 @@ +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal sealed class SyntheticProtocolClient : IAsyncDisposable +{ + private readonly TcpClient _client = new(); + private NetworkStream? _stream; + private readonly MemoryStream _buffer = new(); + + public uint PlayerId { get; private set; } + + public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default) + { + await _client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); + _client.NoDelay = true; + _stream = _client.GetStream(); + var welcome = await ReceiveAsync(cancellationToken).ConfigureAwait(false); + if (JsonHelpers.String(welcome["type"]) != "welcome") throw new InvalidDataException("Server did not send welcome packet."); + await SendAsync(new JsonObject { ["type"] = "hello", ["protocolVersion"] = ProtocolConstants.ProtocolVersion }, cancellationToken).ConfigureAwait(false); + while (true) + { + var packet = await ReceiveAsync(cancellationToken).ConfigureAwait(false); + if (JsonHelpers.String(packet["type"]) != "sessionReady") continue; + if (!JsonHelpers.TryUInt32(packet["playerId"], 1, uint.MaxValue, out var id)) throw new InvalidDataException("sessionReady did not contain a valid playerId."); + PlayerId = id; + break; + } + } + + public async Task SendAsync(JsonObject packet, CancellationToken cancellationToken = default) + { + if (_stream is null) throw new InvalidOperationException("Client is not connected."); + var encoded = JsonSerializer.SerializeToUtf8Bytes(packet); + if (encoded.Length > ProtocolConstants.MaxMessageBytes) throw new InvalidDataException("Synthetic packet exceeds maximum message size."); + await _stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false); + await _stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + } + + public async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + if (_stream is null) throw new InvalidOperationException("Client is not connected."); + var one = new byte[1]; + _buffer.SetLength(0); + while (_buffer.Length <= ProtocolConstants.MaxMessageBytes) + { + var count = await _stream.ReadAsync(one, cancellationToken).ConfigureAwait(false); + if (count == 0) throw new EndOfStreamException("Server closed the connection."); + if (one[0] == (byte)'\n') + { + var data = _buffer.ToArray(); + if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1); + return JsonNode.Parse(data) as JsonObject ?? throw new InvalidDataException("Server message was not a JSON object."); + } + _buffer.WriteByte(one[0]); + } + throw new InvalidDataException("Server message exceeded maximum size."); + } + + public Task SendTransformAsync(double x, double y, double z, string cellId, string worldspaceId = "", string movementType = "normal", CancellationToken cancellationToken = default) => + SendAsync(new JsonObject + { + ["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = z, ["angleZ"] = 0.0, + ["cellId"] = cellId, ["worldspaceId"] = worldspaceId, ["movementType"] = movementType + }, cancellationToken); + + public async ValueTask DisposeAsync() + { + try { _stream?.Dispose(); } catch { } + try { _client.Dispose(); } catch { } + _buffer.Dispose(); + await ValueTask.CompletedTask; + } +} diff --git a/server/TcpTransport.cs b/server/TcpTransport.cs new file mode 100644 index 0000000..5e0df70 --- /dev/null +++ b/server/TcpTransport.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; + +namespace CommonwealthOnline.Server; + +internal sealed class TcpGameConnection : IGameConnection +{ + private readonly TcpClient _client; + private readonly NetworkStream _stream; + private readonly SemaphoreSlim _sendGate = new(1, 1); + private int _closed; + + public TcpGameConnection(TcpClient client) + { + _client = client; + _client.NoDelay = true; + _stream = client.GetStream(); + RemoteEndpoint = (IPEndPoint)(_client.Client.RemoteEndPoint ?? throw new InvalidOperationException("TCP remote endpoint missing")); + ConnectionKey = $"tcp:{RemoteEndpoint.Address}:{RemoteEndpoint.Port}:{Guid.NewGuid():N}"; + } + + public string ConnectionKey { get; } + public string TransportName => "tcp"; + public IPEndPoint RemoteEndpoint { get; } + public bool IsClosed => Volatile.Read(ref _closed) != 0; + internal NetworkStream Stream => _stream; + + public async ValueTask SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default) + { + if (IsClosed) return SendOutcome.NotConnected; + if (packet.Payload.Length > ProtocolConstants.MaxMessageBytes) return SendOutcome.TooLarge; + if (packet.Payload.AsSpan().IndexOf((byte)'\n') >= 0 || packet.Payload.AsSpan().IndexOf((byte)'\r') >= 0) return SendOutcome.Error; + await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (IsClosed) return SendOutcome.NotConnected; + await _stream.WriteAsync(packet.Payload, cancellationToken).ConfigureAwait(false); + await _stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + return SendOutcome.Sent; + } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException) + { + return SendOutcome.NotConnected; + } + finally { _sendGate.Release(); } + } + + public ValueTask DisconnectAsync(int reason, string debug) + { + if (Interlocked.Exchange(ref _closed, 1) != 0) return ValueTask.CompletedTask; + try { _client.Client.Shutdown(SocketShutdown.Both); } catch { } + try { _client.Close(); } catch { } + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync(0, "dispose"); + _sendGate.Dispose(); + _stream.Dispose(); + _client.Dispose(); + } +} + +internal sealed class TcpServerTransport : IAsyncDisposable +{ + private readonly ServerOptions _options; + private readonly IServerIngress _server; + private readonly CancellationTokenSource _shutdown = new(); + private readonly ConcurrentDictionary _clientTasks = new(); + private TcpListener? _listener; + private Task? _acceptTask; + + public TcpServerTransport(ServerOptions options, IServerIngress server) { _options = options; _server = server; } + + public void Start() + { + if (_acceptTask is not null) return; + if (!ServerOptions.TryResolveIpv4(_options.Host, out var address)) throw new InvalidOperationException($"Could not resolve IPv4 bind host {_options.Host}"); + _listener = new TcpListener(address, _options.Port); + _listener.Start(); + _acceptTask = Task.Run(() => AcceptLoopAsync(_shutdown.Token)); + _server.Log($"TCP compatibility transport listening on {_options.Host}:{_options.Port}"); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener!.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException ex) + { + if (!cancellationToken.IsCancellationRequested) _server.Log($"TCP accept error: {ex.Message}", "warning"); + continue; + } + var connection = new TcpGameConnection(client); + bool accepted; + try { accepted = await _server.AcceptConnectionAsync(connection, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) + { + _server.Log($"TCP admission failed for {connection.RemoteEndpoint}: {ex.Message}", "warning"); + await connection.DisposeAsync(); + continue; + } + if (!accepted) { await connection.DisposeAsync(); continue; } + var task = RunClientAsync(connection, cancellationToken); + _clientTasks[connection.ConnectionKey] = task; + _ = task.ContinueWith(_ => _clientTasks.TryRemove(connection.ConnectionKey, out _), TaskScheduler.Default); + } + } + + private async Task RunClientAsync(TcpGameConnection connection, CancellationToken cancellationToken) + { + var readBuffer = new byte[4096]; + using var message = new MemoryStream(4096); + try + { + while (!cancellationToken.IsCancellationRequested && !connection.IsClosed) + { + int count; + try { count = await connection.Stream.ReadAsync(readBuffer, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException) { break; } + if (count == 0) break; + for (var i = 0; i < count; i++) + { + var value = readBuffer[i]; + if (value == (byte)'\n') + { + var data = message.ToArray(); + message.SetLength(0); + if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1); + if (data.Length == 0) continue; + await _server.HandleMessageAsync(connection, data, cancellationToken).ConfigureAwait(false); + if (connection.IsClosed) return; + } + else + { + message.WriteByte(value); + if (message.Length > ProtocolConstants.MaxMessageBytes) + { + await _server.EndSessionForTransportAsync(connection, "packet_too_large", "Packet exceeded maximum line size.").ConfigureAwait(false); + return; + } + } + } + } + } + finally + { + await _server.HandleConnectionClosedAsync(connection).ConfigureAwait(false); + await connection.DisposeAsync(); + } + } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + try { _listener?.Stop(); } catch { } + if (_acceptTask is not null) { try { await _acceptTask.ConfigureAwait(false); } catch { } } + var tasks = _clientTasks.Values.ToArray(); + if (tasks.Length > 0) { try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2)); } catch { } } + _shutdown.Dispose(); + } +} diff --git a/server/TransportAbstractions.cs b/server/TransportAbstractions.cs new file mode 100644 index 0000000..c06021a --- /dev/null +++ b/server/TransportAbstractions.cs @@ -0,0 +1,25 @@ +using System.Net; + +namespace CommonwealthOnline.Server; + +internal enum SendOutcome { Sent, Dropped, Backpressure, NotConnected, TooLarge, Error } + +internal interface IGameConnection : IAsyncDisposable +{ + string ConnectionKey { get; } + string TransportName { get; } + IPEndPoint RemoteEndpoint { get; } + bool IsClosed { get; } + ValueTask SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default); + ValueTask DisconnectAsync(int reason, string debug); +} + +internal interface IServerIngress +{ + Task AcceptConnectionAsync(IGameConnection connection, CancellationToken cancellationToken); + Task HandleMessageAsync(IGameConnection connection, ReadOnlyMemory payload, CancellationToken cancellationToken); + Task HandleConnectionClosedAsync(IGameConnection connection); + Task HandleTransportRejectAsync(IGameConnection connection, string reason, bool warning = false); + Task EndSessionForTransportAsync(IGameConnection connection, string code, string reason); + void Log(string message, string level = "info"); +} diff --git a/server/tests/CommonwealthOnline.Server.Tests.csproj b/server/tests/CommonwealthOnline.Server.Tests.csproj new file mode 100644 index 0000000..599b4fe --- /dev/null +++ b/server/tests/CommonwealthOnline.Server.Tests.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + enable + enable + latest + CommonwealthOnline.Server.Tests + CommonwealthOnline.Server.Tests + + + + + diff --git a/server/tests/Program.cs b/server/tests/Program.cs new file mode 100644 index 0000000..38072c5 --- /dev/null +++ b/server/tests/Program.cs @@ -0,0 +1,349 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using CommonwealthOnline.Server; + +namespace CommonwealthOnline.Server.Tests; + +internal static class Program +{ + private static int _passed; + private static int _failed; + + public static async Task Main() + { + await Run("transport policy", TestTransportPolicy); + await Run("packet codec", TestPacketCodec); + await Run("snapshot sequence", TestSnapshotSequence); + await Run("snapshot envelope", TestSnapshotEnvelope); + await Run("player state validation", TestPlayerStateValidation); + await Run("npc authority epochs", TestNpcAuthority); + await Run("interest filtering", TestInterestFiltering); + await Run("config compatibility", TestConfigCompatibility); + await Run("ban persistence", TestBanStore); + await Run("authoritative server ids and interest", TestAuthoritativeServer); + await Run("durable player state relay", TestPlayerStateRelay); + await Run("npc authority validation", TestNpcAuthorityIntegration); + await Run("combat interest routing", TestCombatInterest); + + Console.WriteLine($"C# server tests: {_passed} passed, {_failed} failed"); + return _failed == 0 ? 0 : 1; + } + + private static async Task Run(string name, Func test) + { + try { await test(); _passed++; Console.WriteLine($"PASS {name}"); } + catch (Exception ex) { _failed++; Console.Error.WriteLine($"FAIL {name}: {ex.Message}"); } + } + + private static Task TestTransportPolicy() + { + Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("transform")); + Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("npcState")); + Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("playerState")); + Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("futureControl")); + return Task.CompletedTask; + } + + private static Task TestPacketCodec() + { + var packet = new JsonObject { ["type"] = "playerState", ["characterName"] = "Nomad" }; + var encoded = PacketCodec.Encode(packet); + Equal("playerState", encoded.PacketType); + Equal(Delivery.ReliableOrdered, encoded.Delivery); + var decoded = PacketCodec.Decode(encoded.Payload); + Equal("Nomad", JsonHelpers.String(decoded["characterName"])); + Throws(() => PacketCodec.Encode(new JsonObject { ["type"] = "x", ["blob"] = new string('a', ProtocolConstants.MaxMessageBytes + 1) })); + Throws(() => PacketCodec.Decode("[]"u8)); + return Task.CompletedTask; + } + + private static Task TestSnapshotSequence() + { + var window = new SequenceWindow(); + True(window.Accept(1)); + True(window.Accept(3)); + False(window.Accept(2)); + False(window.Accept(3)); + True(SequenceWindow.IsNewer(1, uint.MaxValue)); + var counter = new SequenceCounter(); + Equal(1u, counter.Advance()); + return Task.CompletedTask; + } + + private static Task TestSnapshotEnvelope() + { + var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}"); + var wire = GnsSnapshotEnvelope.Encode("transform", payload, 7); + Equal(ProtocolConstants.MaxMessageBytes >= wire.Length, true); + True(GnsSnapshotEnvelope.TryDecode(wire, out var envelope, out var error)); + Equal(null, error); + Equal("transform", envelope.PacketType); + Equal(7u, envelope.Sequence); + SequenceEqual(payload, envelope.Payload); + False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _)); + return Task.CompletedTask; + } + + private static Task TestPlayerStateValidation() + { + var state = new JsonObject + { + ["type"] = "playerState", + ["characterName"] = "Nomad", + ["equippedItems"] = new JsonArray(new JsonObject { ["slot"] = "RightHand", ["formId"] = "ABC" }), + ["appearance"] = new JsonObject + { + ["version"] = 4, + ["raceFormId"] = "13746", + ["hairColorFormId"] = "123", + ["facialHairColorFormId"] = "124", + ["complexionFormId"] = "125", + ["height"] = 1.0, + ["isFemale"] = false, + ["morphWeight"] = new JsonObject { ["thin"] = 0.0, ["muscular"] = 0.0, ["large"] = 0.0 }, + ["bodyTintColor"] = new JsonObject { ["r"] = 255, ["g"] = 255, ["b"] = 255, ["a"] = 255 } + }, + ["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" }) + }; + var normalized = ProtocolValidation.NormalizePlayerState(state); + NotNull(normalized); + Equal("00000123", JsonHelpers.String(((JsonObject)normalized!["appearance"]!)["hairColorFormId"])); + var bad = (JsonObject)state.DeepClone(); + ((JsonArray)bad["actionEvents"]!)[0]!["eventName"] = "bogus"; + Equal(null, ProtocolValidation.NormalizePlayerState(bad)); + return Task.CompletedTask; + } + + private static Task TestNpcAuthority() + { + var manager = new NpcAuthorityManager(); + var scope = new ScopeKey("00000001", ""); + var changes = manager.Reconcile(new[] { (2u, scope), (1u, scope) }); + Equal(1, changes.Count); + Equal(1u, changes[0].PlayerId); + Equal(1u, changes[0].Epoch); + True(manager.Authorize(1, scope, 1)); + changes = manager.Reconcile(Array.Empty<(uint, ScopeKey)>()); + Equal(2u, changes[0].Epoch); + changes = manager.Reconcile(new[] { (3u, scope) }); + Equal(3u, changes[0].Epoch); + False(manager.Authorize(1, scope, 1)); + True(manager.Authorize(3, scope, 3)); + return Task.CompletedTask; + } + + private static Task TestInterestFiltering() + { + var a = Transform("00000001", "000000AA", 0, 0); + var sameCell = Transform("00000001", "000000BB", 999999, 999999); + var near = Transform("00000002", "000000AA", 1000, 1000); + var far = Transform("00000003", "000000AA", 20000, 0); + var otherWorld = Transform("00000004", "000000BB", 0, 0); + True(ProtocolValidation.StatesShareInterest(a, sameCell)); + True(ProtocolValidation.StatesShareInterest(a, near)); + False(ProtocolValidation.StatesShareInterest(a, far)); + False(ProtocolValidation.StatesShareInterest(a, otherWorld)); + return Task.CompletedTask; + } + + private static Task TestConfigCompatibility() + { + var dir = TempDir(); + try + { + var path = Path.Combine(dir, "commonwealth-server.json"); + File.WriteAllText(path, "{\"host\":\"0.0.0.0\",\"port\":7777,\"max_players\":16,\"admin_port\":7779,\"enable_gns_transport\":\"false\"}"); + var options = ServerOptions.Load(path); + False(options.EnableGnsTransport); + Equal(7777, options.Port); + Equal(0, options.Validate().Count); + } + finally { Directory.Delete(dir, true); } + return Task.CompletedTask; + } + + private static Task TestBanStore() + { + var dir = TempDir(); + try + { + var path = Path.Combine(dir, "bans.json"); + var store = new BanStore(path); + store.Ban("127.0.0.1", "test"); + var reloaded = new BanStore(path); + Equal("test", reloaded.GetBan("127.0.0.1")?.Reason); + True(reloaded.Unban("127.0.0.1")); + Equal(0, new BanStore(path).List().Count); + } + finally { Directory.Delete(dir, true); } + return Task.CompletedTask; + } + + private static async Task TestAuthoritativeServer() + { + var fixture = CreateServerFixture(); + await using var server = fixture.Server; + var a = new MemoryConnection("a", 31001); + var b = new MemoryConnection("b", 31002); + True(await server.AcceptConnectionAsync(a, CancellationToken.None)); + True(await server.AcceptConnectionAsync(b, CancellationToken.None)); + await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 }); + await Send(server, b, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 }); + var aId = ReadyId(a); var bId = ReadyId(b); + True(aId != bId); + + await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn")); + await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn")); + a.Clear(); b.Clear(); + await Send(server, a, Transform("00000001", "000000AA", 1, 0)); + False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && JsonHelpers.TryUInt32(p["playerId"], 1, uint.MaxValue, out var id) && id == aId)); + } + + private static async Task TestPlayerStateRelay() + { + var fixture = CreateServerFixture(); + await using var server = fixture.Server; + var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002); + await Activate(server, a, b); + await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn")); + await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn")); + a.Clear(); b.Clear(); + await Send(server, a, new JsonObject + { + ["type"] = "playerState", + ["characterName"] = "Nomad", + ["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" }) + }); + var relay = b.SentPackets.Single(p => JsonHelpers.String(p["type"]) == "playerState"); + Equal("Nomad", JsonHelpers.String(relay["characterName"])); + False(relay.ContainsKey("actionEvents")); + } + + private static async Task TestNpcAuthorityIntegration() + { + var fixture = CreateServerFixture(); + await using var server = fixture.Server; + var a = new MemoryConnection("a", 33001); + True(await server.AcceptConnectionAsync(a, CancellationToken.None)); + await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 }); + await Send(server, a, Transform("00000010", "", 0, 0, "spawn")); + var authority = a.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "npcAuthority"); + JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch); + a.Clear(); + await Send(server, a, new JsonObject + { + ["type"] = "npcState", + ["authorityEpoch"] = epoch, + ["authorityCellId"] = "00000010", + ["authorityWorldspaceId"] = "", + ["npcs"] = new JsonArray(new JsonObject + { + ["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "", + ["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0 + }) + }); + var stats = server.GetCoreStats(); + True(JsonHelpers.TryUInt32(stats["npcStatePacketsReceived"], 0, uint.MaxValue, out var received) && received == 1); + await Send(server, a, new JsonObject + { + ["type"] = "npcState", ["authorityEpoch"] = Math.Max(1u, epoch - 1), ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "", + ["npcs"] = new JsonArray() + }); + stats = server.GetCoreStats(); + True(JsonHelpers.TryUInt32(stats["npcAuthorityRejects"], 0, uint.MaxValue, out var rejects) && rejects >= 1); + } + + private static async Task TestCombatInterest() + { + var fixture = CreateServerFixture(); + await using var server = fixture.Server; + var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002); + await Activate(server, a, b); + var bId = ReadyId(b); + await Send(server, a, Transform("00000100", "000000AA", 0, 0, "spawn")); + await Send(server, b, Transform("00000200", "000000BB", 0, 0, "spawn")); + a.Clear(); b.Clear(); + await Send(server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 }); + False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "combatHit")); + var stats = server.GetCoreStats(); + True(JsonHelpers.TryUInt32(stats["packetsRejected"], 0, uint.MaxValue, out var rejected) && rejected >= 1); + } + + private static (AuthoritativeServer Server, string Dir) CreateServerFixture() + { + var dir = TempDir(); + var path = Path.Combine(dir, "commonwealth-server.json"); + var options = new ServerOptions { ConfigPath = path, Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 }; + return (new AuthoritativeServer(options), dir); + } + + private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections) + { + foreach (var connection in connections) + { + True(await server.AcceptConnectionAsync(connection, CancellationToken.None)); + await Send(server, connection, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 }); + } + } + + private static uint ReadyId(MemoryConnection connection) + { + var ready = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady"); + True(JsonHelpers.TryUInt32(ready["playerId"], 1, uint.MaxValue, out var id)); + return id; + } + + private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) => + server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None); + + private static JsonObject Transform(string cell, string world, double x, double y, string movement = "normal") => new() + { + ["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = 0.0, ["angleZ"] = 0.0, + ["cellId"] = cell, ["worldspaceId"] = world, ["movementType"] = movement + }; + + private static string TempDir() + { + var path = Path.Combine(Path.GetTempPath(), "co-csharp-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void True(bool value) { if (!value) throw new Exception("expected true"); } + private static void False(bool value) { if (value) throw new Exception("expected false"); } + private static void NotNull(object? value) { if (value is null) throw new Exception("expected non-null"); } + private static void Equal(T expected, T actual) where T : notnull { if (!EqualityComparer.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); } + private static void SequenceEqual(byte[] expected, byte[] actual) { if (!expected.AsSpan().SequenceEqual(actual)) throw new Exception("byte sequences differ"); } + private static void Throws(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception($"expected {typeof(T).Name}"); } + + private sealed class MemoryConnection : IGameConnection + { + private readonly object _gate = new(); + private readonly List _sent = new(); + private int _closed; + + public MemoryConnection(string name, int port) + { + ConnectionKey = "memory:" + name; + RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port); + } + + public string ConnectionKey { get; } + public string TransportName => "memory"; + public IPEndPoint RemoteEndpoint { get; } + public bool IsClosed => Volatile.Read(ref _closed) != 0; + public IReadOnlyList SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } } + + public ValueTask SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default) + { + if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected); + lock (_gate) _sent.Add(PacketCodec.Decode(packet.Payload)); + return ValueTask.FromResult(SendOutcome.Sent); + } + + public ValueTask DisconnectAsync(int reason, string debug) { Interlocked.Exchange(ref _closed, 1); return ValueTask.CompletedTask; } + public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose"); + public void Clear() { lock (_gate) _sent.Clear(); } + } +}