Add C# dedicated server replacement

This commit is contained in:
Nomads_Reach
2026-08-16 02:23:04 -04:00
parent 0024436ce8
commit ddadf87423
17 changed files with 3516 additions and 0 deletions
+39
View File
@@ -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
+311
View File
@@ -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<Task> _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<JsonObject> 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<JsonObject> 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();
}
}
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Tests")]
+836
View File
@@ -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<string, ClientSession> _clients = new(StringComparer.Ordinal);
private readonly Dictionary<uint, JsonObject> _lastPlayerStateByPlayerId = new();
private readonly Dictionary<ScopeKey, JsonObject> _lastNpcStateByScope = new();
private readonly NpcAuthorityManager _npcAuthority = new();
private readonly Dictionary<string, Queue<double>> _connectAttempts = new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _stats = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _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<string, string>? 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<bool> 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<byte> 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 ?? "<null>"}"); 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<bool> 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<bool> 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<double>();
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<AuthorityChange> 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) ? "<interior>" : 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<int> 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<bool> 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<BanEntry> 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<bool> 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<bool> 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<string, string> snapshot; lock (_gate) snapshot = new Dictionary<string, string>(_serverWorldState, StringComparer.Ordinal);
var packets = new List<JsonObject>();
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();
}
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AssemblyName>CommonwealthOnline.Server</AssemblyName>
<RootNamespace>CommonwealthOnline.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Remove="tests/**/*.cs" />
</ItemGroup>
</Project>
+135
View File
@@ -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<string> Validate()
{
var errors = new List<string>();
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<int>(out var number)) return number;
if (node is JsonValue textValue && textValue.TryGetValue<string>(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<bool>(out var boolean)) return boolean;
if (node is JsonValue intValue && intValue.TryGetValue<int>(out var number) && number is 0 or 1) return number == 1;
if (node is JsonValue textValue && textValue.TryGetValue<string>(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");
}
}
+317
View File
@@ -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<ScopeKey>
{
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<ScopeKey, AuthorityAssignment> _assignments = new();
private readonly Dictionary<ScopeKey, uint> _lastEpoch = new();
public void Clear()
{
_assignments.Clear();
_lastEpoch.Clear();
}
public AuthorityAssignment? Get(ScopeKey scope) => _assignments.TryGetValue(scope, out var value) ? value : null;
public IReadOnlyCollection<AuthorityAssignment> 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<AuthorityChange> 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<AuthorityChange>();
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<string, BanEntry> _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<BanEntry> 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<string, BanEntry>(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;
}
}
+320
View File
@@ -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<CreateDelegate>("co_gns_server_create");
_destroy = Get<DestroyDelegate>("co_gns_server_destroy");
_localPort = Get<LocalPortDelegate>("co_gns_server_local_port");
_connectionCount = Get<ConnectionCountDelegate>("co_gns_server_connection_count");
_poll = Get<PollDelegate>("co_gns_server_poll");
_send = Get<SendDelegate>("co_gns_server_send");
_disconnect = Get<DisconnectDelegate>("co_gns_server_disconnect");
_remoteIpv4 = Get<RemoteIpv4Delegate>("co_gns_server_remote_ipv4");
var errorBuffer = Marshal.AllocHGlobal(512);
try
{
new Span<byte>((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<byte>();
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<byte> 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<T>(string name) where T : Delegate => Marshal.GetDelegateForFunctionPointer<T>(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<string, SequenceCounter> _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<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
{
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
lock (_gate)
{
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
ReadOnlySpan<byte> 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<uint, GnsGameConnection> _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<byte> 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();
}
}
+337
View File
@@ -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<int> Main(string[] args)
{
try
{
return await RunAsync(args).ConfigureAwait(false);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ERROR] {ex.Message}");
return 2;
}
}
private static async Task<int> RunAsync(string[] args)
{
if (args.Length == 0 || args[0] is "help" or "--help" or "-h")
{
PrintHelp();
return 0;
}
var command = args[0].ToLowerInvariant();
return command switch
{
"serve" => await ServeAsync(args[1..]).ConfigureAwait(false),
"status" => await AdminCommandAsync(new JsonObject { ["cmd"] = "status" }, args[1..]).ConfigureAwait(false),
"clients" or "users" => await AdminCommandAsync(new JsonObject { ["cmd"] = "clients" }, args[1..]).ConfigureAwait(false),
"bans" => await AdminCommandAsync(new JsonObject { ["cmd"] = "bans" }, args[1..]).ConfigureAwait(false),
"kick" => await KickAsync(args[1..]).ConfigureAwait(false),
"ban" => await BanAsync(args[1..]).ConfigureAwait(false),
"unban" => await UnbanAsync(args[1..]).ConfigureAwait(false),
"world" => await WorldAsync(args[1..]).ConfigureAwait(false),
"config" => ConfigCommand(args[1..]),
"load-test" => await LoadTestAsync(args[1..]).ConfigureAwait(false),
_ => Unknown(command)
};
}
private static async Task<int> ServeAsync(string[] args)
{
var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json";
var fullConfigPath = Path.GetFullPath(configPath);
if (!File.Exists(fullConfigPath))
{
Console.WriteLine($"Generating default configuration at {fullConfigPath}");
ServerOptions.CreateDefault(fullConfigPath);
}
var options = ServerOptions.Load(fullConfigPath);
if (GetOption(args, "--host", "-H") is { } host) options.Host = host;
if (GetOption(args, "--port", "-p") is { } portText)
{
if (!int.TryParse(portText, out var port)) throw new ArgumentException("--port requires an integer.");
options.Port = port;
}
var errors = options.Validate();
if (errors.Count > 0)
{
foreach (var error in errors) Console.Error.WriteLine($"[ERROR] {error}");
return 2;
}
await using var runtime = new ServerRuntime(options);
runtime.Server.LogMessage += (message, level) =>
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
Console.WriteLine($"{timestamp} {level.ToUpperInvariant()} {message}");
};
runtime.Start();
PrintStartup(options);
using var shutdown = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; shutdown.Cancel(); };
PosixSignalRegistration? sigterm = null;
if (!OperatingSystem.IsWindows())
{
sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; shutdown.Cancel(); });
}
try
{
if (HasFlag(args, "--interactive", "-i") && !Console.IsInputRedirected)
await RunInteractiveAsync(options, shutdown).ConfigureAwait(false);
else
await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) { }
finally { sigterm?.Dispose(); }
return 0;
}
private static async Task RunInteractiveAsync(ServerOptions options, CancellationTokenSource shutdown)
{
Console.WriteLine("Type help for commands. Type quit to stop.");
while (!shutdown.IsCancellationRequested)
{
Console.Write("commonwealth> ");
var line = Console.ReadLine();
if (line is null) break;
var parts = SplitCommandLine(line);
if (parts.Length == 0) continue;
if (parts[0] is "quit" or "exit" or "stop") { shutdown.Cancel(); break; }
if (parts[0] == "help") { PrintInteractiveHelp(); continue; }
var forwarded = parts[0] switch
{
"users" => new JsonObject { ["cmd"] = "clients" },
"clients" => new JsonObject { ["cmd"] = "clients" },
"status" => new JsonObject { ["cmd"] = "status" },
"bans" => new JsonObject { ["cmd"] = "bans" },
_ => null
};
if (forwarded is not null)
{
await PrintAdminResponseAsync(forwarded, options).ConfigureAwait(false);
continue;
}
if (parts[0] == "kick" && parts.Length >= 2 && uint.TryParse(parts[1], out var kickId))
{
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = kickId, ["reason"] = ReadReason(parts, 2) }, options).ConfigureAwait(false);
continue;
}
if (parts[0] == "ban" && parts.Length >= 2)
{
var request = new JsonObject { ["cmd"] = "ban", ["reason"] = ReadReason(parts, 2) };
if (uint.TryParse(parts[1], out var banId)) request["playerId"] = banId; else request["ip"] = parts[1];
await PrintAdminResponseAsync(request, options).ConfigureAwait(false);
continue;
}
if (parts[0] == "unban" && parts.Length >= 2)
{
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = parts[1] }, options).ConfigureAwait(false);
continue;
}
if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "time")
{
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_time", ["hhmm"] = parts[2] }, options).ConfigureAwait(false);
continue;
}
if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "weather")
{
await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_weather", ["weather"] = parts[2] }, options).ConfigureAwait(false);
continue;
}
Console.WriteLine("Unknown command. Type help.");
}
}
private static async Task<int> AdminCommandAsync(JsonObject request, string[] args)
{
var options = LoadOptionsForAdmin(args);
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
}
private static async Task<int> KickAsync(string[] args)
{
if (args.Length == 0 || !uint.TryParse(args[0], out var id)) throw new ArgumentException("kick requires PLAYER_ID");
var options = LoadOptionsForAdmin(args[1..]);
var request = new JsonObject { ["cmd"] = "kick", ["playerId"] = id, ["reason"] = GetOption(args, "--reason") ?? string.Empty };
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
}
private static async Task<int> BanAsync(string[] args)
{
if (args.Length == 0) throw new ArgumentException("ban requires PLAYER_ID_OR_IP");
var request = new JsonObject { ["cmd"] = "ban", ["reason"] = GetOption(args, "--reason") ?? string.Empty };
if (uint.TryParse(args[0], out var id)) request["playerId"] = id; else request["ip"] = args[0];
var options = LoadOptionsForAdmin(args[1..]);
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
}
private static async Task<int> UnbanAsync(string[] args)
{
if (args.Length == 0) throw new ArgumentException("unban requires IP");
var options = LoadOptionsForAdmin(args[1..]);
return await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = args[0] }, options).ConfigureAwait(false) ? 0 : 1;
}
private static async Task<int> WorldAsync(string[] args)
{
if (args.Length < 2) throw new ArgumentException("world requires 'time HHmm' or 'weather FORM_ID'");
var request = args[0].ToLowerInvariant() switch
{
"time" => new JsonObject { ["cmd"] = "world_time", ["hhmm"] = args[1] },
"weather" => new JsonObject { ["cmd"] = "world_weather", ["weather"] = args[1] },
_ => throw new ArgumentException("world requires 'time' or 'weather'")
};
var options = LoadOptionsForAdmin(args[2..]);
return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1;
}
private static int ConfigCommand(string[] args)
{
if (args.Length == 0 || args[0] != "init") throw new ArgumentException("config requires 'init [OUTPUT_PATH]'");
var path = Path.GetFullPath(args.Length > 1 ? args[1] : "commonwealth-server.json");
if (File.Exists(path)) throw new IOException($"Config already exists: {path}");
ServerOptions.CreateDefault(path);
Console.WriteLine(path);
return 0;
}
private static async Task<int> LoadTestAsync(string[] args)
{
var host = GetOption(args, "--host", "-H") ?? "127.0.0.1";
var port = int.TryParse(GetOption(args, "--port", "-p"), out var parsedPort) ? parsedPort : 7777;
var count = int.TryParse(GetOption(args, "--clients", "-n"), out var parsedCount) ? parsedCount : 16;
if (count is < 1 or > 256) throw new ArgumentOutOfRangeException(nameof(count), "client count must be 1-256");
var clients = new List<SyntheticProtocolClient>();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
for (var i = 0; i < count; i++)
{
var client = new SyntheticProtocolClient();
await client.ConnectAsync(host, port, timeout.Token).ConfigureAwait(false);
clients.Add(client);
var cell = (0x1000 + i).ToString("X8");
await client.SendTransformAsync(i * 1000.0, 0, 0, cell, string.Empty, "spawn", timeout.Token).ConfigureAwait(false);
}
var ids = clients.Select(x => x.PlayerId).ToArray();
if (ids.Distinct().Count() != ids.Length) throw new InvalidOperationException("Server assigned duplicate player IDs.");
Console.WriteLine($"Connected {clients.Count} clients with unique server-owned IDs: {string.Join(", ", ids)}");
return 0;
}
finally
{
foreach (var client in clients) await client.DisposeAsync();
}
}
private static ServerOptions LoadOptionsForAdmin(string[] args)
{
var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json";
return ServerOptions.Load(Path.GetFullPath(configPath));
}
private static async Task<bool> PrintAdminResponseAsync(JsonObject request, ServerOptions options)
{
JsonObject response;
try { response = await AdminClient.SendAsync(request, options.AdminPort, options.AdminTokenPath).ConfigureAwait(false); }
catch (Exception ex)
{
Console.Error.WriteLine($"Error talking to admin port 127.0.0.1:{options.AdminPort}: {ex.Message}");
return false;
}
Console.WriteLine(response.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
return JsonHelpers.Boolean(response["ok"]) == true;
}
private static void PrintStartup(ServerOptions options)
{
Console.WriteLine($"Commonwealth Online Server\nBind: {options.Host}:{options.Port}\nMax players: {options.MaxPlayers}\nLAN discovery: UDP {LanDiscoveryService.DiscoveryPort}\nAdmin: 127.0.0.1:{options.AdminPort}\nGNS: {(options.EnableGnsTransport ? "enabled" : "disabled")}\n");
}
private static string? GetOption(string[] args, params string[] names)
{
for (var i = 0; i < args.Length; i++)
{
foreach (var name in names)
{
if (args[i] == name)
{
if (i + 1 >= args.Length) throw new ArgumentException($"{name} requires a value.");
return args[i + 1];
}
if (args[i].StartsWith(name + "=", StringComparison.Ordinal)) return args[i][(name.Length + 1)..];
}
}
return null;
}
private static bool HasFlag(string[] args, params string[] names) => args.Any(arg => names.Contains(arg, StringComparer.Ordinal));
private static string[] SplitCommandLine(string input)
{
var result = new List<string>();
var current = new System.Text.StringBuilder();
var quoted = false;
for (var i = 0; i < input.Length; i++)
{
var ch = input[i];
if (ch == '"') { quoted = !quoted; continue; }
if (char.IsWhiteSpace(ch) && !quoted)
{
if (current.Length > 0) { result.Add(current.ToString()); current.Clear(); }
continue;
}
current.Append(ch);
}
if (current.Length > 0) result.Add(current.ToString());
return result.ToArray();
}
private static string ReadReason(string[] parts, int start)
{
var index = Array.IndexOf(parts, "--reason", start);
return index >= 0 && index + 1 < parts.Length ? parts[index + 1] : string.Empty;
}
private static int Unknown(string command)
{
Console.Error.WriteLine($"Unknown command: {command}");
PrintHelp();
return 2;
}
private static void PrintHelp()
{
Console.WriteLine("""
Commonwealth Online Server
Commands:
serve [--config PATH] [--host HOST] [--port PORT] [--interactive]
status [--config PATH]
clients [--config PATH]
users [--config PATH]
kick PLAYER_ID [--reason TEXT] [--config PATH]
ban PLAYER_ID_OR_IP [--reason TEXT] [--config PATH]
unban IP [--config PATH]
bans [--config PATH]
world time HHmm [--config PATH]
world weather FORM_ID [--config PATH]
config init [OUTPUT_PATH]
load-test [--host HOST] [--port PORT] [--clients N]
""");
}
private static void PrintInteractiveHelp()
{
Console.WriteLine("help | status | users | kick ID [--reason TEXT] | ban ID_OR_IP [--reason TEXT] | unban IP | bans | world time HHmm | world weather FORM_ID | quit");
}
}
+176
View File
@@ -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<string> MovementTransitionTypes = new(StringComparer.Ordinal)
{
"teleport", "cell_change", "worldspace_change", "load", "spawn", "fast_travel"
};
public static readonly HashSet<string> 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<byte> 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<string>(out var text) ? text : null;
public static bool? Boolean(JsonNode? node) => node is JsonValue value && value.TryGetValue<bool>(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<bool>(out _)) return false;
if (value.TryGetValue<uint>(out var u) && u >= min && u <= max) { result = u; return true; }
if (value.TryGetValue<long>(out var l) && l >= min && l <= max) { result = (uint)l; return true; }
if (value.TryGetValue<double>(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<bool>(out _)) return false;
double d;
if (value.TryGetValue<double>(out var direct)) d = direct;
else if (value.TryGetValue<long>(out var l)) d = l;
else if (value.TryGetValue<decimal>(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<byte> Magic => "COG2"u8;
private const byte Version = 1;
public const int HeaderSize = 12;
public static byte[] Encode(string packetType, ReadOnlySpan<byte> 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<byte> 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;
}
}
+341
View File
@@ -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;
}
}
+53
View File
@@ -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();
}
}
+76
View File
@@ -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<JsonObject> 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;
}
}
+169
View File
@@ -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<SendOutcome> 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<string, Task> _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();
}
}
+25
View File
@@ -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<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default);
ValueTask DisconnectAsync(int reason, string debug);
}
internal interface IServerIngress
{
Task<bool> AcceptConnectionAsync(IGameConnection connection, CancellationToken cancellationToken);
Task HandleMessageAsync(IGameConnection connection, ReadOnlyMemory<byte> 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");
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AssemblyName>CommonwealthOnline.Server.Tests</AssemblyName>
<RootNamespace>CommonwealthOnline.Server.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../CommonwealthOnline.Server.csproj" />
</ItemGroup>
</Project>
+349
View File
@@ -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<int> 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<Task> 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<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "x", ["blob"] = new string('a', ProtocolConstants.MaxMessageBytes + 1) }));
Throws<PacketCodecException>(() => 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<string?>(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<JsonObject?>(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>(T expected, T actual) where T : notnull { if (!EqualityComparer<T>.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<T>(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<JsonObject> _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<JsonObject> SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } }
public ValueTask<SendOutcome> 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(); }
}
}