From f5d826113017043d6d08b46debfbc77a6b77f15b Mon Sep 17 00:00:00 2001 From: Nomads_Reach <144523850+NomadsReach@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:44:28 -0400 Subject: [PATCH] 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. --- server/AuthoritativeServer.cs | 36 ++++++--- server/acceptance/Program.cs | 146 +++++++++++++++++++++++++++++----- 2 files changed, 148 insertions(+), 34 deletions(-) diff --git a/server/AuthoritativeServer.cs b/server/AuthoritativeServer.cs index 2dd3686..d545c85 100644 --- a/server/AuthoritativeServer.cs +++ b/server/AuthoritativeServer.cs @@ -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(); 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); diff --git a/server/acceptance/Program.cs b/server/acceptance/Program.cs index 176c4f9..331374f 100644 --- a/server/acceptance/Program.cs +++ b/server/acceptance/Program.cs @@ -12,10 +12,10 @@ namespace CommonwealthOnline.Server.Acceptance; // exercises framing, admission, relay, interest filtering and session teardown // over an actual connection. // -// Covered here: the Baseline protocol, TCP compatibility, and interest-management -// sections of the matrix. GNS sections wait on #2 (real-bridge loopback); the -// 60s idle-timeout and 32/64-client load/fault runs are deferred follow-ups -// (see the summary printed at the end). +// Covered here: the Baseline protocol, TCP compatibility, interest-management, +// movement/combat-validation, and NPC-authority sections of the matrix. GNS +// sections wait on #2 (real-bridge loopback); the 32/64-client load/fault runs +// are deferred follow-ups (see the summary printed at the end). internal static class Program { private static int _passed; @@ -39,13 +39,18 @@ internal static class Program await Run("tcp: two-client transform/playerState/worldState smoke", TwoClientSmoke); await Run("player-state: action events are not replay-cached to late joiners", ActionEventsNotReplayCached); await Run("tcp: disconnect leaves no stale session; reconnect works", DisconnectNoStale); + await Run("movement: impossible movement is rejected, corrected, and not relayed", ImpossibleMovementRejected); + await Run("combat: self-targeted combat hit is rejected", SelfTargetedCombatRejected); await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant); - await Run("interest: multi-client across cells, no cross-cell transform spam", MultiClientNoCrossCellSpam); + await Run("interest: 16 clients across cells, no cross-cell transform spam", SixteenClientsNoCrossCellSpam); + await Run("authority: independent populated scopes get independent authorities", IndependentNpcAuthorities); + await Run("authority: a stale npc authority epoch is rejected over transport", StaleNpcAuthorityEpochRejected); await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses); + await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses); Console.WriteLine(); Console.WriteLine($"acceptance: {_passed} passed, {_failed} failed"); - Console.WriteLine("deferred (follow-ups): 60s idle-timeout, 32/64-client load + packet-loss/reorder + reconnect-churn, and all GNS sections (blocked on #2)."); + Console.WriteLine("deferred (follow-ups): 32/64-client load + packet-loss/reorder + reconnect-churn, and all GNS sections (blocked on #2)."); return _failed == 0 ? 0 : 1; } @@ -227,33 +232,32 @@ internal static class Program True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered"); } - // The per-IP connect-attempt throttle admits 8 connections per 10s from one - // source address, so over a single loopback IP this case proves the no-cross- - // cell-spam property at 8 clients. Scaling to the matrix's 16/32/64-client load - // runs needs distinct source IPs (loopback aliasing) or a loopback connect- - // throttle exemption; that is the load-testing follow-up. - private const int MultiClientCount = 8; + // 16 clients over a single loopback IP exceed the production per-IP connect + // throttle (8/10s), so this case runs the test server with a widened local + // connect budget. The 32/64-client load runs remain follow-ups. + private const int SixteenClientCount = 16; - private static async Task MultiClientNoCrossCellSpam() + private static async Task SixteenClientsNoCrossCellSpam() { - await using var s = new TestServer(); + await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 }); var clients = new List(); try { - for (var i = 0; i < MultiClientCount; i++) + for (var i = 0; i < SixteenClientCount; i++) { var c = await Connect(s.Port); clients.Add(c); - // every client sits in its own cell AND worldspace, far apart - await c.SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1_000_000.0, 0, "spawn")); + // every client sits in its own cell AND worldspace (coordinates kept + // well within MaxAbsCoordinate; distinct scopes do the isolating) + await c.SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0, 0, "spawn")); } var ids = clients.Select(c => c.PlayerId).ToArray(); - Equal(MultiClientCount, ids.Distinct().Count(), "unique server-owned ids for every client"); + Equal(SixteenClientCount, ids.Distinct().Count(), "unique server-owned ids for every client"); foreach (var c in clients) await Drain(c, 150); // each client moves; nobody should see anybody else's transform for (var i = 0; i < clients.Count; i++) - await clients[i].SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1_000_000.0 + 10, 0)); + await clients[i].SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0 + 10, 0)); foreach (var c in clients) True(await ExpectNone(c, p => Type(p) == "transform", 500), "client received no cross-cell transform spam"); @@ -264,6 +268,91 @@ internal static class Program } } + // ---- movement / combat validation -------------------------------------- + + private static async Task ImpossibleMovementRejected() + { + await using var s = new TestServer(); + await using var a = await Connect(s.Port); + await using var b = await Connect(s.Port); + // both anchor in the same cell so b would normally receive a's transforms + await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn")); + await b.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn")); + await Drain(a, 300); await Drain(b, 300); + var rejectedBefore = Stat(s.Server, "movementPacketsRejected"); + + // a "normal" jump of a million units in the same cell is physically impossible + await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 1_000_000, 0, "normal")); + + NotNull(await ReceiveUntil(a, p => Type(p) == "positionCorrection", 3000), + "sender received a position correction"); + True(await ExpectNone(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 1000), + "impossible movement was not relayed to b"); + await SpinUntil(() => Stat(s.Server, "movementPacketsRejected") > rejectedBefore, 1500); + True(Stat(s.Server, "movementPacketsRejected") > rejectedBefore, "impossible movement counted as rejected"); + } + + private static async Task SelfTargetedCombatRejected() + { + await using var s = new TestServer(); + await using var a = await Connect(s.Port); + await a.SendAsync(TransformPacket("0000C0FE", "0000C0FE", 0, 0, "spawn")); + await Drain(a, 300); + var rejectedBefore = Stat(s.Server, "packetsRejected"); + var routedBefore = Stat(s.Server, "combatHitsRouted"); + + await a.SendAsync(new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = a.PlayerId, ["sequence"] = 1, ["damage"] = 10.0 }); + + await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000); + True(Stat(s.Server, "packetsRejected") > rejectedBefore, "self-targeted combat hit was rejected"); + Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "self-targeted combat hit was not routed"); + } + + // ---- npc authority ------------------------------------------------------ + + private static async Task IndependentNpcAuthorities() + { + await using var s = new TestServer(); + await using var a = await Connect(s.Port); + await using var b = await Connect(s.Port); + // a and b spawn into distinct, non-overlapping scopes + await a.SendAsync(TransformPacket("0000A111", "0000A111", 0, 0, "spawn")); + await b.SendAsync(TransformPacket("0000B222", "0000B222", 5_000_000, 0, "spawn")); + + var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000); + var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 3000); + NotNull(grantA, "a received authority for its own scope"); + NotNull(grantB, "b received authority for its own scope"); + True(UInt(grantA!["authorityEpoch"]) >= 1, "a's authority carries an epoch"); + True(UInt(grantB!["authorityEpoch"]) >= 1, "b's authority carries an epoch"); + Equal(2u, Stat(s.Server, "npcAuthorityScopes"), "two independent authority scopes exist"); + } + + private static async Task StaleNpcAuthorityEpochRejected() + { + await using var s = new TestServer(); + await using var a = await Connect(s.Port); + await a.SendAsync(TransformPacket("0000E999", "0000E999", 0, 0, "spawn")); + var grant = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000); + NotNull(grant, "a became the authority for its scope"); + var epoch = UInt(grant!["authorityEpoch"]); + await Drain(a, 200); + var rejectsBefore = Stat(s.Server, "npcAuthorityRejects"); + + // submit npcState claiming a wrong (future) authority epoch + await a.SendAsync(new JsonObject + { + ["type"] = "npcState", + ["authorityEpoch"] = epoch + 1, + ["authorityCellId"] = "0000E999", + ["authorityWorldspaceId"] = "0000E999", + ["npcs"] = new JsonArray() + }); + + await SpinUntil(() => Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, 2000); + True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "npcState with a stale/wrong epoch was rejected"); + } + // ---- session teardown --------------------------------------------------- private static async Task HandshakeTimeoutCloses() @@ -277,6 +366,21 @@ internal static class Program Equal(0u, Stat(s.Server, "connectedClients"), "still no active clients after reap"); } + private static async Task IdleTimeoutCloses() + { + // Short idle timeout so the reaper (500ms tick) fires quickly; handshake + // timeout keeps its default so the client can activate first. + await using var s = new TestServer(tuning: new ServerTuning { ClientIdleTimeoutSeconds = 2.0 }); + await using var a = await Connect(s.Port); + await a.SendAsync(TransformPacket("0000171E", "0000171E", 0, 0, "spawn")); + await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000); + Equal(1u, Stat(s.Server, "connectedClients"), "client is active before going idle"); + + // stay idle past the 2s idle timeout; the reaper must close the session + await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 6000); + Equal(0u, Stat(s.Server, "connectedClients"), "idle active session was reaped"); + } + // ---- infrastructure ----------------------------------------------------- private static async Task Run(string name, Func test) @@ -377,7 +481,7 @@ internal static class Program private readonly TcpServerTransport _tcp; private readonly string _dir; - public TestServer(int maxPlayers = 32) + public TestServer(int maxPlayers = 32, ServerTuning? tuning = null) { Port = FreePort(); _dir = Path.Combine(Path.GetTempPath(), "co-accept-" + Guid.NewGuid().ToString("N")); @@ -387,7 +491,7 @@ internal static class Program ConfigPath = Path.Combine(_dir, "commonwealth-server.json"), Host = "127.0.0.1", Port = Port, AdminPort = FreePort(), MaxPlayers = maxPlayers }; - Server = new AuthoritativeServer(options); + Server = new AuthoritativeServer(options, tuning); _tcp = new TcpServerTransport(options, Server); _tcp.Start(); }