Files
Commonwealth-Online-Server/server/AuthoritativeServer.cs
T
Nomads_ReachandGitHub ece634450b Acceptance: load/fault tier — 16/32/64 clients, burst, churn, handoff-under-load (#15) (#24)
* Acceptance: add the load/fault tier (16/32/64 clients, burst, churn, handoff)

Extend the end-to-end harness (issue #15), harness-only — no server change.
Uses the existing ServerTuning seam to widen the local connect budget and
capacity so many clients can run over a single loopback IP.

- 16/32/64 clients across distinct cells: unique server-owned ids and no
  cross-cell transform spam (generalized from the prior 16-client case)
- burst transform traffic engages the rate limiter without tearing sessions
  down, and the server still relays a fresh transform afterward
- reconnect churn (12 cycles): ids stay monotonic and never reused, and each
  disconnect leaves no stale active session
- authority handoff stays deterministic under churn: retiring the current
  authority repeatedly hands off to the next lowest id with strictly
  increasing epochs

Packet loss/reorder on snapshot traffic is inherently an unreliable-transport
(GNS) property and stays deferred with the GNS sections (blocked on #2).

26/26 pass on real sockets, deterministic across repeated runs.

* Strip verbose comments from the acceptance harness and ServerTuning

Remove the prose/narration comments across the end-to-end harness (keeping only
section dividers) and the ServerTuning header block, matching the repo's terse
comment style.
2026-08-16 20:24:16 -04:00

844 lines
46 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using System.Text.Json.Nodes;
namespace CommonwealthOnline.Server;
internal sealed record ServerTuning
{
public int MaxPacketsPerSecond { get; init; } = 120;
public int MaxConnectAttempts { get; init; } = 8;
public double ConnectAttemptWindowSeconds { get; init; } = 10.0;
public double ClientIdleTimeoutSeconds { get; init; } = 60.0;
public double ClientHandshakeTimeoutSeconds { get; init; } = 10.0;
public static ServerTuning Default { get; } = new();
}
internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
{
private readonly ServerTuning _tuning;
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, ServerTuning? tuning = null)
{
_options = options;
_tuning = tuning ?? ServerTuning.Default;
_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 <= _tuning.MaxPacketsPerSecond) client.RateViolations = Math.Max(0, client.RateViolations - 1);
client.RateWindowStart = now; client.RateWindowCount = 0; client.RateWindowBlocked = false;
}
client.RateWindowCount++;
if (client.RateWindowCount <= _tuning.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 - _tuning.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 <= _tuning.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 > _tuning.ClientHandshakeTimeoutSeconds)
{
Log($"Handshake timeout: {client.Label}", "warning");
await DisconnectClientAsync(client).ConfigureAwait(false);
}
else if (client.GameplayActive && client.LastPacketAt is { } last && now - last > _tuning.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();
}
}