diff --git a/server/AuthoritativeServer.cs b/server/AuthoritativeServer.cs index d545c85..17bfe2a 100644 --- a/server/AuthoritativeServer.cs +++ b/server/AuthoritativeServer.cs @@ -4,9 +4,6 @@ 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; diff --git a/server/acceptance/Program.cs b/server/acceptance/Program.cs index b390812..8f1a5c4 100644 --- a/server/acceptance/Program.cs +++ b/server/acceptance/Program.cs @@ -6,16 +6,6 @@ using CommonwealthOnline.Server; namespace CommonwealthOnline.Server.Acceptance; -// End-to-end acceptance harness for issue #15. Unlike the in-memory unit tests, -// this stands up the real AuthoritativeServer behind the real TCP transport on a -// loopback port and drives real SyntheticProtocolClient sockets through it, so it -// exercises framing, admission, relay, interest filtering and session teardown -// over an actual connection. -// -// 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; @@ -45,17 +35,22 @@ internal static class Program await Run("combat: combat hit at a disconnected target is rejected", DisconnectedTargetCombatRejected); await Run("combat: out-of-interest combat hit is rejected", OutOfInterestCombatRejected); await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant); - 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("authority: disconnect deterministically hands off to a new owner", AuthorityHandoffOnDisconnect); await Run("authority: cell transition hands off and blocks the previous owner", AuthorityHandoffOnCellTransition); await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses); await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses); + await Run("load: 16 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(16)); + await Run("load: 32 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(32)); + await Run("load: 64 clients across cells, no cross-cell transform spam", () => NoCrossCellSpam(64)); + await Run("load: burst transform traffic keeps the server responsive", BurstTransformTraffic); + await Run("load: reconnect churn leaves no stale sessions", ReconnectChurn); + await Run("load: authority handoff stays deterministic under churn", AuthorityHandoffUnderLoad); Console.WriteLine(); Console.WriteLine($"acceptance: {_passed} passed, {_failed} failed"); - Console.WriteLine("deferred (follow-ups): 32/64-client load + packet-loss/reorder + reconnect-churn, and all GNS sections (blocked on #2)."); + Console.WriteLine("deferred (follow-ups): packet loss/reorder on snapshot traffic and all GNS sections (both need the unreliable GNS path, blocked on #2)."); return _failed == 0 ? 0 : 1; } @@ -78,8 +73,6 @@ internal static class Program await using var b = await Connect(s.Port); await Drain(a, 300); await Drain(b, 300); - // a claims a bogus playerId inside its own transform; the server must relay - // b a transform stamped with a's real server-owned id, never the claim. var spoof = TransformPacket("0000AAAA", "0000BBBB", 0, 0, "spawn"); spoof["playerId"] = 999999; await a.SendAsync(spoof); @@ -115,8 +108,6 @@ internal static class Program await raw.ExpectType("sessionReady", 2000); var transformsBefore = Stat(s.Server, "transformPacketsReceived"); - // A single line larger than the protocol maximum must terminate the session - // at the transport before it is ever decoded or applied. await raw.WriteRaw(new byte[ProtocolConstants.MaxMessageBytes + 1024]); True(await raw.WaitClosed(4000), "server closed the connection on oversize input"); @@ -127,7 +118,6 @@ internal static class Program { await using var s = new TestServer(); await using var a = await Connect(s.Port); - // Burst well past the 120 packets/second window in a single window. for (var i = 0; i < 400; i++) { try { await a.SendAsync(new JsonObject { ["type"] = "keepAlive" }); } @@ -142,19 +132,16 @@ internal static class Program private static async Task TwoClientSmoke() { await using var s = new TestServer(); - await using var a = await Connect(s.Port); // a connects first -> world-state host + await using var a = await Connect(s.Port); await using var b = await Connect(s.Port); await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn")); await Drain(a, 400); await Drain(b, 400); - // transform relay await a.SendAsync(TransformPacket("0000CAFE", "0000F00D", 10, 0)); NotNull(await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 3000), "b received a's transform"); - // durable playerState relay: an in-interest peer receives the durable - // fields (and, being in scope, the live action events too). await a.SendAsync(new JsonObject { ["type"] = "playerState", @@ -165,7 +152,6 @@ internal static class Program NotNull(ps, "b received a's playerState"); Equal("Nomad", JsonHelpers.String(ps!["characterName"]), "playerState carried the character name"); - // world-state from the host relays await a.SendAsync(new JsonObject { ["type"] = "worldState", ["timeHours"] = 12.0 }); NotNull(await ReceiveUntil(b, p => Type(p) == "worldState", 3000), "b received world state from the host"); } @@ -183,8 +169,6 @@ internal static class Program }); await Drain(a, 300); - // a late joiner is replayed a's durable state, which must carry the durable - // fields but never the discrete action events. await using var late = await Connect(s.Port); var replay = await ReceiveUntil(late, p => Type(p) == "playerState" && UInt(p["playerId"]) == a.PlayerId, 3000); NotNull(replay, "late joiner received a's durable player state"); @@ -217,7 +201,6 @@ internal static class Program 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 await a.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn")); await Drain(a, 400); await Drain(b, 400); @@ -227,8 +210,6 @@ internal static class Program "same-cell transform relayed to b"); var filteredBefore = Stat(s.Server, "transformPacketsInterestFiltered"); - // a makes a valid transition to a distant, unrelated cell/worldspace; the - // move itself is legal (transition type) but b is no longer in interest. await a.SendAsync(TransformPacket("00000111", "00000222", 5_000_000, 5_000_000, "cell_change")); await Drain(a, 300); True(await ExpectNone(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 1200), @@ -237,35 +218,28 @@ internal static class Program True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered"); } - // 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 SixteenClientsNoCrossCellSpam() + private static async Task NoCrossCellSpam(int count) { - await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 }); + await using var s = new TestServer(maxPlayers: Math.Max(64, count), + tuning: new ServerTuning { MaxConnectAttempts = count * 4 }); var clients = new List(); try { - for (var i = 0; i < SixteenClientCount; i++) + for (var i = 0; i < count; i++) { var c = await Connect(s.Port); clients.Add(c); - // 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(SixteenClientCount, ids.Distinct().Count(), "unique server-owned ids for every client"); - foreach (var c in clients) await Drain(c, 150); + Equal(count, ids.Distinct().Count(), "unique server-owned ids for every client"); + foreach (var c in clients) await Drain(c, 100); - // 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 * 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"); + True(await ExpectNone(c, p => Type(p) == "transform", 400), "client received no cross-cell transform spam"); } finally { @@ -280,13 +254,11 @@ internal static class Program 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), @@ -322,13 +294,11 @@ internal static class Program await b.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn")); await Drain(a, 300); await Drain(b, 300); - // first hit at sequence 5 routes to the in-interest target await a.SendAsync(CombatHit(b.PlayerId, 5)); NotNull(await ReceiveUntil(b, p => Type(p) == "combatHit", 3000), "in-interest combat hit routed to the target"); var routedBefore = Stat(s.Server, "combatHitsRouted"); var rejectedBefore = Stat(s.Server, "packetsRejected"); - // an older sequence must be rejected and not routed await a.SendAsync(CombatHit(b.PlayerId, 3)); await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000); True(Stat(s.Server, "packetsRejected") > rejectedBefore, "replayed combat sequence was rejected"); @@ -357,7 +327,7 @@ internal static class Program await using var a = await Connect(s.Port); await using var b = await Connect(s.Port); await a.SendAsync(TransformPacket("0000CB03", "0000CB03", 0, 0, "spawn")); - await b.SendAsync(TransformPacket("0000CB04", "0000CB04", 1000, 0, "spawn")); // distinct scope + await b.SendAsync(TransformPacket("0000CB04", "0000CB04", 1000, 0, "spawn")); await Drain(a, 300); await Drain(b, 300); var rejectedBefore = Stat(s.Server, "packetsRejected"); var routedBefore = Stat(s.Server, "combatHitsRouted"); @@ -381,7 +351,6 @@ internal static class Program 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")); @@ -405,7 +374,6 @@ internal static class Program 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", @@ -422,16 +390,15 @@ internal static class Program private static async Task AuthorityHandoffOnDisconnect() { await using var s = new TestServer(); - var a = await Connect(s.Port); // connects first -> lower id -> initial authority + var a = await Connect(s.Port); await using var b = await Connect(s.Port); await a.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn")); var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000); NotNull(grantA, "a is the initial authority for the shared scope"); var epochA = UInt(grantA!["authorityEpoch"]); - await b.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn")); // b joins the same scope + await b.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn")); await Drain(a, 300); await Drain(b, 300); - // a leaves; authority must deterministically hand off to b with a newer epoch await a.DisposeAsync(); var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000); NotNull(grantB, "authority handed off to b after a disconnected"); @@ -443,20 +410,18 @@ internal static class Program await using var s = new TestServer(); await using var a = await Connect(s.Port); await using var b = await Connect(s.Port); - await a.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // a authority for scope X + await a.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000); NotNull(grantA, "a is authority for scope X"); var epochX1 = UInt(grantA!["authorityEpoch"]); - await b.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // b joins scope X + await b.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); await Drain(a, 300); await Drain(b, 300); - // a transitions out to a different cell; scope X must hand off to b await a.SendAsync(TransformPacket("0000CE20", "0000CE20", 0, 0, "cell_change")); var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000); NotNull(grantB, "scope X handed off to b after a transitioned away"); True(UInt(grantB!["authorityEpoch"]) > epochX1, "handoff carries a newer epoch"); - // the previous owner can no longer submit npcState for the reassigned scope await Drain(a, 200); var rejectsBefore = Stat(s.Server, "npcAuthorityRejects"); await a.SendAsync(new JsonObject @@ -471,6 +436,94 @@ internal static class Program True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "previous owner cannot submit for the reassigned scope"); } + // ---- load / fault ------------------------------------------------------- + + private static async Task BurstTransformTraffic() + { + await using var s = new TestServer(); + await using var a = await Connect(s.Port); + await using var b = await Connect(s.Port); + await a.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "spawn")); + await b.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "spawn")); + await Drain(a, 300); await Drain(b, 300); + var receivedBefore = Stat(s.Server, "transformPacketsReceived"); + + for (var i = 0; i < 400; i++) + { + try { await a.SendAsync(TransformPacket("0000B057", "0000B057", 5, 5, "normal")); } + catch { break; } + } + await SpinUntil(() => Stat(s.Server, "rateLimitedPackets") > 0, 3000); + True(Stat(s.Server, "rateLimitedPackets") > 0, "the burst engaged the rate limiter"); + True(Stat(s.Server, "transformPacketsReceived") > receivedBefore, "the server processed transforms during the burst"); + Equal(2u, Stat(s.Server, "connectedClients"), "both sessions survived the burst"); + + await Drain(b, 300); + var relayed = false; + for (var attempt = 0; attempt < 5 && !relayed; attempt++) + relayed = await RelayWorks(a, b); + True(relayed, "server relays a fresh transform after the burst"); + } + + private static async Task RelayWorks(SyntheticProtocolClient a, SyntheticProtocolClient b) + { + await a.SendAsync(TransformPacket("0000B057", "0000B057", 6, 6, "normal")); + return await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 800) is not null; + } + + private static async Task ReconnectChurn() + { + await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 128 }); + uint lastId = 0; + for (var k = 0; k < 12; k++) + { + var c = await Connect(s.Port); + True(c.PlayerId > lastId, "server-owned ids are monotonic and never reused across reconnects"); + lastId = c.PlayerId; + await c.SendAsync(TransformPacket("0000C401", "0000C401", 0, 0, "spawn")); + await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000); + await c.DisposeAsync(); + await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 3000); + Equal(0u, Stat(s.Server, "connectedClients"), "each disconnect leaves no stale active session"); + } + await using var final = await Connect(s.Port); + True(final.PlayerId > lastId, "server still assigns fresh ids after churn"); + } + + private static async Task AuthorityHandoffUnderLoad() + { + const int n = 6; + await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 }); + var clients = new List(); + try + { + for (var i = 0; i < n; i++) + { + var c = await Connect(s.Port); + clients.Add(c); + await c.SendAsync(TransformPacket("0000A11D", "0000A11D", 0, 0, "spawn")); + } + var watcher = clients[n - 1]; + await Drain(watcher, 300); + uint lastEpoch = 0; + + for (var i = 0; i < 3; i++) + { + await clients[i].DisposeAsync(); + var expected = clients[i + 1].PlayerId; + var grant = await ReceiveUntil(watcher, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == expected, 4000); + NotNull(grant, $"authority handed off to the next owner after retiring holder {i}"); + var epoch = UInt(grant!["authorityEpoch"]); + True(epoch > lastEpoch, "each handoff carries a strictly newer epoch"); + lastEpoch = epoch; + } + } + finally + { + foreach (var c in clients) await c.DisposeAsync(); + } + } + // ---- session teardown --------------------------------------------------- private static async Task HandshakeTimeoutCloses() @@ -478,7 +531,6 @@ internal static class Program await using var s = new TestServer(); using var raw = await RawClient.Connect(s.Port); await raw.ExpectType("welcome", 2000); - // deliberately never send hello; the reaper must close the pending session Equal(0u, Stat(s.Server, "connectedClients"), "pending session is not counted as active"); True(await raw.WaitClosed(14000), "server reaped the unactivated session after the handshake timeout"); Equal(0u, Stat(s.Server, "connectedClients"), "still no active clients after reap"); @@ -486,15 +538,12 @@ internal static class Program 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"); } @@ -625,8 +674,6 @@ internal static class Program } } - // Minimal raw client for cases the framed SyntheticProtocolClient can't express - // (oversize input, and connecting without completing the handshake). private sealed class RawClient : IDisposable { private readonly TcpClient _client; @@ -690,11 +737,11 @@ internal static class Program while (true) { var n = await _stream.ReadAsync(one, cts.Token); - if (n == 0) return true; // clean EOF -> server closed + if (n == 0) return true; } } catch (OperationCanceledException) { return false; } - catch (IOException) { return true; } // reset also counts as closed + catch (IOException) { return true; } catch (SocketException) { return true; } }