Add a small injected ServerTuning seam (production defaults unchanged; ServerRuntime still constructs the server without it) so end-to-end cases can use short timeouts and a wider local connect budget. New real-transport acceptance cases: - impossible movement rejected, position-corrected, and not relayed - self-targeted combat hit rejected and not routed - independent populated scopes receive independent NPC authorities - a stale/wrong NPC authority epoch is rejected over transport - idle timeout closes a stale active session - multi-client no-cross-cell-spam scaled to 16 clients (widened local connect budget) 16/16 pass on real sockets, deterministic across repeated runs.
847 lines
46 KiB
C#
847 lines
46 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace CommonwealthOnline.Server;
|
|
|
|
// Runtime limits with the shipped production defaults. Injected only so tests can
|
|
// shorten timeouts and widen the local connect budget; ServerRuntime constructs
|
|
// the server without it, so production behavior is unchanged.
|
|
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();
|
|
}
|
|
}
|