Add C# dedicated server replacement
This commit is contained in:
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Tests")]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user