using System.Net; using System.Net.Sockets; using System.Text; using System.Text.Json.Nodes; using CommonwealthOnline.Server; namespace CommonwealthOnline.Server.Acceptance; internal static class Program { private static int _passed; private static int _failed; public static async Task Main() { Console.WriteLine("Commonwealth Online - end-to-end acceptance harness (TCP)"); if (!CanBindLoopback()) { Console.WriteLine("SKIP: environment cannot bind a loopback TCP listener; end-to-end acceptance not executed here."); return 0; } await Run("baseline: two clients handshake welcome->hello->sessionReady", BaselineHandshake); await Run("baseline: server-owned ids are unique and not client-spoofable", ServerOwnedIdsNotSpoofable); await Run("baseline: malformed json rejected before gameplay mutation", MalformedJsonRejected); await Run("baseline: oversized payload rejected before gameplay mutation", OversizeRejected); await Run("baseline: sustained packet-rate abuse triggers rate limit", RateLimitTrips); 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("combat: replayed/out-of-order combat sequence is rejected", ReplayedCombatSequenceRejected); 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("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): packet loss/reorder on snapshot traffic and all GNS sections (both need the unreliable GNS path, blocked on #2)."); return _failed == 0 ? 0 : 1; } // ---- baseline protocol -------------------------------------------------- private static async Task BaselineHandshake() { await using var s = new TestServer(); await using var a = await Connect(s.Port); await using var b = await Connect(s.Port); True(a.PlayerId != 0, "client a received a server-owned playerId"); True(b.PlayerId != 0, "client b received a server-owned playerId"); True(a.PlayerId != b.PlayerId, "player ids are unique"); } private static async Task ServerOwnedIdsNotSpoofable() { await using var s = new TestServer(); await using var a = await Connect(s.Port); await using var b = await Connect(s.Port); await Drain(a, 300); await Drain(b, 300); var spoof = TransformPacket("0000AAAA", "0000BBBB", 0, 0, "spawn"); spoof["playerId"] = 999999; await a.SendAsync(spoof); var relay = await ReceiveUntil(b, p => Type(p) == "transform", 3000); NotNull(relay, "b received a's transform"); Equal(a.PlayerId, UInt(relay!["playerId"]), "relayed transform carries the server-owned id"); } private static async Task MalformedJsonRejected() { await using var s = new TestServer(); using var raw = await RawClient.Connect(s.Port); await raw.ExpectType("welcome", 2000); await raw.WriteLine(Encoding.UTF8.GetBytes("{\"type\":\"hello\",\"protocolVersion\":2}")); await raw.ExpectType("sessionReady", 2000); var before = Stat(s.Server, "packetsRejected"); var transformsBefore = Stat(s.Server, "transformPacketsReceived"); await raw.WriteLine(Encoding.UTF8.GetBytes("{ this is not valid json")); await SpinUntil(() => Stat(s.Server, "packetsRejected") > before, 2000); True(Stat(s.Server, "packetsRejected") > before, "malformed json incremented packetsRejected"); Equal(transformsBefore, Stat(s.Server, "transformPacketsReceived"), "no gameplay mutation from malformed json"); } private static async Task OversizeRejected() { await using var s = new TestServer(); using var raw = await RawClient.Connect(s.Port); await raw.ExpectType("welcome", 2000); await raw.WriteLine(Encoding.UTF8.GetBytes("{\"type\":\"hello\",\"protocolVersion\":2}")); await raw.ExpectType("sessionReady", 2000); var transformsBefore = Stat(s.Server, "transformPacketsReceived"); await raw.WriteRaw(new byte[ProtocolConstants.MaxMessageBytes + 1024]); True(await raw.WaitClosed(4000), "server closed the connection on oversize input"); Equal(transformsBefore, Stat(s.Server, "transformPacketsReceived"), "oversize input caused no gameplay mutation"); } private static async Task RateLimitTrips() { await using var s = new TestServer(); await using var a = await Connect(s.Port); for (var i = 0; i < 400; i++) { try { await a.SendAsync(new JsonObject { ["type"] = "keepAlive" }); } catch { break; } } await SpinUntil(() => Stat(s.Server, "rateLimitedPackets") > 0, 3000); True(Stat(s.Server, "rateLimitedPackets") > 0, "sustained burst tripped the rate limiter"); } // ---- tcp compatibility -------------------------------------------------- private static async Task TwoClientSmoke() { 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("0000CAFE", "0000F00D", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000CAFE", "0000F00D", 0, 0, "spawn")); await Drain(a, 400); await Drain(b, 400); 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"); await a.SendAsync(new JsonObject { ["type"] = "playerState", ["characterName"] = "Nomad", ["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" }) }); var ps = await ReceiveUntil(b, p => Type(p) == "playerState" && UInt(p["playerId"]) == a.PlayerId, 3000); NotNull(ps, "b received a's playerState"); Equal("Nomad", JsonHelpers.String(ps!["characterName"]), "playerState carried the character name"); 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"); } private static async Task ActionEventsNotReplayCached() { await using var s = new TestServer(); await using var a = await Connect(s.Port); await a.SendAsync(TransformPacket("0000AC70", "0000AC70", 0, 0, "spawn")); await a.SendAsync(new JsonObject { ["type"] = "playerState", ["characterName"] = "Nomad", ["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" }) }); await Drain(a, 300); 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"); Equal("Nomad", JsonHelpers.String(replay!["characterName"]), "durable state replayed the character name"); True(!replay.ContainsKey("actionEvents"), "discrete action events were not replay-cached to the late joiner"); } private static async Task DisconnectNoStale() { await using var s = new TestServer(); var a = await Connect(s.Port); await using var b = await Connect(s.Port); await SpinUntil(() => Stat(s.Server, "connectedClients") == 2, 3000); Equal(2u, Stat(s.Server, "connectedClients"), "two clients active"); await a.DisposeAsync(); await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 4000); Equal(1u, Stat(s.Server, "connectedClients"), "disconnect removed the session"); await using var a2 = await Connect(s.Port); await SpinUntil(() => Stat(s.Server, "connectedClients") == 2, 3000); Equal(2u, Stat(s.Server, "connectedClients"), "reconnect restored two active sessions"); True(a2.PlayerId != 0, "reconnected client received a fresh server-owned id"); } // ---- interest management ------------------------------------------------ private static async Task InterestSameCellVsDistant() { 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("0000BEEF", "0000BEEF", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 0, 0, "spawn")); await Drain(a, 400); await Drain(b, 400); await a.SendAsync(TransformPacket("0000BEEF", "0000BEEF", 5, 5)); NotNull(await ReceiveUntil(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 3000), "same-cell transform relayed to b"); var filteredBefore = Stat(s.Server, "transformPacketsInterestFiltered"); 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), "distant transform was not relayed to b"); await SpinUntil(() => Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, 1500); True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered"); } private static async Task NoCrossCellSpam(int count) { 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 < count; i++) { var c = await Connect(s.Port); clients.Add(c); 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(count, ids.Distinct().Count(), "unique server-owned ids for every client"); foreach (var c in clients) await Drain(c, 100); 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", 400), "client received no cross-cell transform spam"); } finally { foreach (var c in clients) await c.DisposeAsync(); } } // ---- 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); 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"); 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"); } private static async Task ReplayedCombatSequenceRejected() { 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("0000CB01", "0000CB01", 0, 0, "spawn")); await b.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn")); await Drain(a, 300); await Drain(b, 300); 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"); 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"); Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "replayed combat sequence was not routed"); True(await ExpectNone(b, p => Type(p) == "combatHit", 500), "target did not receive the replayed hit"); } private static async Task DisconnectedTargetCombatRejected() { await using var s = new TestServer(); await using var a = await Connect(s.Port); await a.SendAsync(TransformPacket("0000CB02", "0000CB02", 0, 0, "spawn")); await Drain(a, 300); var rejectedBefore = Stat(s.Server, "packetsRejected"); var routedBefore = Stat(s.Server, "combatHitsRouted"); await a.SendAsync(CombatHit(999999, 1)); await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000); True(Stat(s.Server, "packetsRejected") > rejectedBefore, "combat hit at a disconnected target was rejected"); Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "disconnected-target combat was not routed"); } private static async Task OutOfInterestCombatRejected() { 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("0000CB03", "0000CB03", 0, 0, "spawn")); 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"); await a.SendAsync(CombatHit(b.PlayerId, 1)); await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000); True(Stat(s.Server, "packetsRejected") > rejectedBefore, "out-of-interest combat hit was rejected"); Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "out-of-interest combat was not routed"); True(await ExpectNone(b, p => Type(p) == "combatHit", 500), "distant target did not receive the hit"); } private static JsonObject CombatHit(uint targetPlayerId, uint sequence) => new() { ["type"] = "combatHit", ["targetPlayerId"] = targetPlayerId, ["sequence"] = sequence, ["damage"] = 10.0 }; // ---- 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); 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"); 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"); } private static async Task AuthorityHandoffOnDisconnect() { await using var s = new TestServer(); 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")); await Drain(a, 300); await Drain(b, 300); 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"); True(UInt(grantB!["authorityEpoch"]) > epochA, "handoff carries a newer epoch"); } private static async Task AuthorityHandoffOnCellTransition() { 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")); 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")); await Drain(a, 300); await Drain(b, 300); 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"); await Drain(a, 200); var rejectsBefore = Stat(s.Server, "npcAuthorityRejects"); await a.SendAsync(new JsonObject { ["type"] = "npcState", ["authorityEpoch"] = epochX1, ["authorityCellId"] = "0000CE10", ["authorityWorldspaceId"] = "0000CE10", ["npcs"] = new JsonArray() }); await SpinUntil(() => Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, 2000); 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() { await using var s = new TestServer(); using var raw = await RawClient.Connect(s.Port); await raw.ExpectType("welcome", 2000); 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"); } private static async Task IdleTimeoutCloses() { 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"); 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) { try { await test(); _passed++; Console.WriteLine($"PASS {name}"); } catch (Exception ex) { _failed++; Console.Error.WriteLine($"FAIL {name}: {ex.Message}"); } } private static bool CanBindLoopback() { try { var p = FreePort(); return p > 0; } catch (SocketException) { return false; } } private static int FreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); try { return ((IPEndPoint)listener.LocalEndpoint).Port; } finally { listener.Stop(); } } private static async Task Connect(int port) { var client = new SyntheticProtocolClient(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await client.ConnectAsync("127.0.0.1", port, timeout.Token); return client; } private static JsonObject TransformPacket(string cell, string world, double x, double y, string movement = "normal") => new() { ["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = 0.0, ["angleZ"] = 0.0, ["cellId"] = cell, ["worldspaceId"] = world, ["movementType"] = movement }; private static string? Type(JsonObject packet) => JsonHelpers.String(packet["type"]); private static uint UInt(JsonNode? node) => JsonHelpers.TryUInt32(node, 0, uint.MaxValue, out var v) ? v : 0; private static uint Stat(AuthoritativeServer server, string name) => JsonHelpers.TryUInt32(server.GetCoreStats()[name], 0, uint.MaxValue, out var v) ? v : 0; private static async Task TryReceive(SyntheticProtocolClient client, int timeoutMs) { using var cts = new CancellationTokenSource(timeoutMs); try { return await client.ReceiveAsync(cts.Token); } catch (OperationCanceledException) { return null; } catch (EndOfStreamException) { return null; } catch (IOException) { return null; } catch (InvalidDataException) { return null; } } private static async Task ReceiveUntil(SyntheticProtocolClient client, Func predicate, int timeoutMs) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { var remaining = (int)Math.Max(1, (deadline - DateTime.UtcNow).TotalMilliseconds); var packet = await TryReceive(client, remaining); if (packet is null) break; if (predicate(packet)) return packet; } return null; } private static async Task ExpectNone(SyntheticProtocolClient client, Func predicate, int windowMs) { var deadline = DateTime.UtcNow.AddMilliseconds(windowMs); while (DateTime.UtcNow < deadline) { var remaining = (int)Math.Max(1, (deadline - DateTime.UtcNow).TotalMilliseconds); var packet = await TryReceive(client, remaining); if (packet is null) break; if (predicate(packet)) return false; } return true; } private static Task Drain(SyntheticProtocolClient client, int windowMs) => ExpectNone(client, _ => false, windowMs); private static async Task SpinUntil(Func condition, int timeoutMs) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(50); } } private static void True(bool value, string what) { if (!value) throw new Exception($"expected: {what}"); } private static void NotNull(object? value, string what) { if (value is null) throw new Exception($"expected non-null: {what}"); } private static void Equal(T expected, T actual, string what) { if (!EqualityComparer.Default.Equals(expected, actual)) throw new Exception($"{what}: expected {expected}, got {actual}"); } private sealed class TestServer : IAsyncDisposable { private readonly TcpServerTransport _tcp; private readonly string _dir; public TestServer(int maxPlayers = 32, ServerTuning? tuning = null) { Port = FreePort(); _dir = Path.Combine(Path.GetTempPath(), "co-accept-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_dir); var options = new ServerOptions { ConfigPath = Path.Combine(_dir, "commonwealth-server.json"), Host = "127.0.0.1", Port = Port, AdminPort = FreePort(), MaxPlayers = maxPlayers }; Server = new AuthoritativeServer(options, tuning); _tcp = new TcpServerTransport(options, Server); _tcp.Start(); } public int Port { get; } public AuthoritativeServer Server { get; } public async ValueTask DisposeAsync() { await _tcp.DisposeAsync(); await Server.DisposeAsync(); try { Directory.Delete(_dir, true); } catch { } } } private sealed class RawClient : IDisposable { private readonly TcpClient _client; private readonly NetworkStream _stream; private RawClient(TcpClient client) { _client = client; _stream = client.GetStream(); } public static async Task Connect(int port) { var client = new TcpClient { NoDelay = true }; await client.ConnectAsync(IPAddress.Loopback, port); return new RawClient(client); } public async Task WriteLine(byte[] payload) { await _stream.WriteAsync(payload); await _stream.WriteAsync(new byte[] { (byte)'\n' }); } public Task WriteRaw(byte[] bytes) => _stream.WriteAsync(bytes).AsTask(); public async Task ExpectType(string type, int timeoutMs) { var packet = await ReadLine(timeoutMs) ?? throw new Exception($"expected {type}, got connection close"); var actual = JsonHelpers.String(packet["type"]); if (actual != type) throw new Exception($"expected {type}, got {actual}"); } public async Task ReadLine(int timeoutMs) { using var cts = new CancellationTokenSource(timeoutMs); var buffer = new MemoryStream(); var one = new byte[1]; try { while (buffer.Length <= ProtocolConstants.MaxMessageBytes) { var n = await _stream.ReadAsync(one, cts.Token); if (n == 0) return null; if (one[0] == (byte)'\n') { var data = buffer.ToArray(); if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1); return JsonNode.Parse(data) as JsonObject; } buffer.WriteByte(one[0]); } } catch (OperationCanceledException) { return null; } catch (IOException) { return null; } return null; } public async Task WaitClosed(int timeoutMs) { using var cts = new CancellationTokenSource(timeoutMs); var one = new byte[1]; try { while (true) { var n = await _stream.ReadAsync(one, cts.Token); if (n == 0) return true; } } catch (OperationCanceledException) { return false; } catch (IOException) { return true; } catch (SocketException) { return true; } } public void Dispose() { try { _stream.Dispose(); } catch { } try { _client.Dispose(); } catch { } } } }