Expand the acceptance matrix: movement, combat, NPC authority, timeouts, 16 clients (#21)
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.
This commit is contained in:
@@ -4,14 +4,23 @@ 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 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 ServerTuning _tuning;
|
||||
private readonly ServerOptions _options;
|
||||
private readonly BanStore _banStore;
|
||||
private readonly object _gate = new();
|
||||
@@ -29,9 +38,10 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
||||
private uint? _worldStateHostPlayerId;
|
||||
private readonly double _startedAt = JsonHelpers.UnixTime();
|
||||
|
||||
public AuthoritativeServer(ServerOptions options)
|
||||
public AuthoritativeServer(ServerOptions options, ServerTuning? tuning = null)
|
||||
{
|
||||
_options = options;
|
||||
_tuning = tuning ?? ServerTuning.Default;
|
||||
_banStore = new BanStore(options.BansPath);
|
||||
foreach (var name in new[]
|
||||
{
|
||||
@@ -352,11 +362,11 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
||||
var now = MonotonicClock.Now;
|
||||
if (now - client.RateWindowStart >= 1.0)
|
||||
{
|
||||
if (client.RateWindowCount <= MaxPacketsPerSecond) client.RateViolations = Math.Max(0, client.RateViolations - 1);
|
||||
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 <= MaxPacketsPerSecond) return true;
|
||||
if (client.RateWindowCount <= _tuning.MaxPacketsPerSecond) return true;
|
||||
Increment("rateLimitedPackets");
|
||||
if (!client.RateWindowBlocked)
|
||||
{
|
||||
@@ -370,13 +380,13 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
||||
private bool AllowConnectAttempt(string ip)
|
||||
{
|
||||
var now = MonotonicClock.Now;
|
||||
var cutoff = now - ConnectAttemptWindowSeconds;
|
||||
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 <= MaxConnectAttempts;
|
||||
return queue.Count <= _tuning.MaxConnectAttempts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,12 +700,12 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
||||
var now = JsonHelpers.UnixTime();
|
||||
foreach (var client in snapshot)
|
||||
{
|
||||
if (!client.GameplayActive && now - client.ConnectedAt > ClientHandshakeTimeoutSeconds)
|
||||
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 > ClientIdleTimeoutSeconds)
|
||||
else if (client.GameplayActive && client.LastPacketAt is { } last && now - last > _tuning.ClientIdleTimeoutSeconds)
|
||||
{
|
||||
Log($"Idle timeout: player {client.PlayerId}", "warning");
|
||||
await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user