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:
Nomads_Reach
2026-08-16 19:44:28 -04:00
committed by GitHub
parent 6edc16ed20
commit f5d8261130
2 changed files with 148 additions and 34 deletions
+23 -13
View File
@@ -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);