* Acceptance: add the load/fault tier (16/32/64 clients, burst, churn, handoff) Extend the end-to-end harness (issue #15), harness-only — no server change. Uses the existing ServerTuning seam to widen the local connect budget and capacity so many clients can run over a single loopback IP. - 16/32/64 clients across distinct cells: unique server-owned ids and no cross-cell transform spam (generalized from the prior 16-client case) - burst transform traffic engages the rate limiter without tearing sessions down, and the server still relays a fresh transform afterward - reconnect churn (12 cycles): ids stay monotonic and never reused, and each disconnect leaves no stale active session - authority handoff stays deterministic under churn: retiring the current authority repeatedly hands off to the next lowest id with strictly increasing epochs Packet loss/reorder on snapshot traffic is inherently an unreliable-transport (GNS) property and stays deferred with the GNS sections (blocked on #2). 26/26 pass on real sockets, deterministic across repeated runs. * Strip verbose comments from the acceptance harness and ServerTuning Remove the prose/narration comments across the end-to-end harness (keeping only section dividers) and the ServerTuning header block, matching the repo's terse comment style.
755 lines
36 KiB
C#
755 lines
36 KiB
C#
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<int> 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<SyntheticProtocolClient>();
|
|
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<bool> 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<SyntheticProtocolClient>();
|
|
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<Task> 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<SyntheticProtocolClient> 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<JsonObject?> 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<JsonObject?> ReceiveUntil(SyntheticProtocolClient client, Func<JsonObject, bool> 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<bool> ExpectNone(SyntheticProtocolClient client, Func<JsonObject, bool> 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<bool> 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>(T expected, T actual, string what)
|
|
{
|
|
if (!EqualityComparer<T>.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<RawClient> 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<JsonObject?> 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<bool> 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 { }
|
|
}
|
|
}
|
|
}
|