Complete C# server cutover

This commit is contained in:
Nomads_Reach
2026-08-16 02:36:32 -04:00
parent ddadf87423
commit 757e614cdd
79 changed files with 923 additions and 13129 deletions
+101 -127
View File
@@ -14,18 +14,17 @@ internal static class Program
{
await Run("transport policy", TestTransportPolicy);
await Run("packet codec", TestPacketCodec);
await Run("snapshot sequence", TestSnapshotSequence);
await Run("snapshot sequencing", TestSnapshotSequencing);
await Run("snapshot envelope", TestSnapshotEnvelope);
await Run("player state validation", TestPlayerStateValidation);
await Run("npc authority epochs", TestNpcAuthority);
await Run("npc authority epochs", TestNpcAuthorityEpochs);
await Run("interest filtering", TestInterestFiltering);
await Run("config compatibility", TestConfigCompatibility);
await Run("ban persistence", TestBanStore);
await Run("authoritative server ids and interest", TestAuthoritativeServer);
await Run("durable player state relay", TestPlayerStateRelay);
await Run("npc authority validation", TestNpcAuthorityIntegration);
await Run("combat interest routing", TestCombatInterest);
await Run("ban persistence", TestBanPersistence);
await Run("server-owned ids and interest relay", TestServerOwnedIdsAndInterest);
await Run("durable player state relay", TestDurablePlayerStateRelay);
await Run("npc authority enforcement", TestNpcAuthorityEnforcement);
await Run("combat interest enforcement", TestCombatInterest);
Console.WriteLine($"C# server tests: {_passed} passed, {_failed} failed");
return _failed == 0 ? 0 : 1;
}
@@ -51,14 +50,13 @@ internal static class Program
var encoded = PacketCodec.Encode(packet);
Equal("playerState", encoded.PacketType);
Equal(Delivery.ReliableOrdered, encoded.Delivery);
var decoded = PacketCodec.Decode(encoded.Payload);
Equal("Nomad", JsonHelpers.String(decoded["characterName"]));
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "x", ["blob"] = new string('a', ProtocolConstants.MaxMessageBytes + 1) }));
Equal("Nomad", JsonHelpers.String(PacketCodec.Decode(encoded.Payload)["characterName"]));
Throws<PacketCodecException>(() => PacketCodec.Decode("[]"u8));
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "oversize", ["payload"] = new string('x', ProtocolConstants.MaxMessageBytes + 1) }));
return Task.CompletedTask;
}
private static Task TestSnapshotSequence()
private static Task TestSnapshotSequencing()
{
var window = new SequenceWindow();
True(window.Accept(1));
@@ -66,28 +64,26 @@ internal static class Program
False(window.Accept(2));
False(window.Accept(3));
True(SequenceWindow.IsNewer(1, uint.MaxValue));
var counter = new SequenceCounter();
Equal(1u, counter.Advance());
False(SequenceWindow.IsNewer(uint.MaxValue, 1));
return Task.CompletedTask;
}
private static Task TestSnapshotEnvelope()
{
var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}");
var wire = GnsSnapshotEnvelope.Encode("transform", payload, 7);
Equal(ProtocolConstants.MaxMessageBytes >= wire.Length, true);
True(GnsSnapshotEnvelope.TryDecode(wire, out var envelope, out var error));
var encoded = GnsSnapshotEnvelope.Encode("transform", payload, 7);
True(GnsSnapshotEnvelope.TryDecode(encoded, out var envelope, out var error));
Equal<string?>(null, error);
Equal("transform", envelope.PacketType);
Equal(7u, envelope.Sequence);
SequenceEqual(payload, envelope.Payload);
True(payload.AsSpan().SequenceEqual(envelope.Payload));
False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _));
return Task.CompletedTask;
}
private static Task TestPlayerStateValidation()
{
var state = new JsonObject
var packet = new JsonObject
{
["type"] = "playerState",
["characterName"] = "Nomad",
@@ -106,28 +102,32 @@ internal static class Program
},
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
};
var normalized = ProtocolValidation.NormalizePlayerState(state);
var normalized = ProtocolValidation.NormalizePlayerState(packet);
NotNull(normalized);
Equal("00000123", JsonHelpers.String(((JsonObject)normalized!["appearance"]!)["hairColorFormId"]));
var bad = (JsonObject)state.DeepClone();
((JsonArray)bad["actionEvents"]!)[0]!["eventName"] = "bogus";
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(bad));
var appearance = normalized!["appearance"] as JsonObject;
NotNull(appearance);
Equal("00000123", JsonHelpers.String(appearance!["hairColorFormId"]));
var invalidActions = new JsonObject
{
["type"] = "playerState",
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "invalid" })
};
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(invalidActions));
return Task.CompletedTask;
}
private static Task TestNpcAuthority()
private static Task TestNpcAuthorityEpochs()
{
var manager = new NpcAuthorityManager();
var scope = new ScopeKey("00000001", "");
var changes = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
Equal(1, changes.Count);
Equal(1u, changes[0].PlayerId);
Equal(1u, changes[0].Epoch);
var first = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
Equal(1u, first.Single().PlayerId);
Equal(1u, first.Single().Epoch);
True(manager.Authorize(1, scope, 1));
changes = manager.Reconcile(Array.Empty<(uint, ScopeKey)>());
Equal(2u, changes[0].Epoch);
changes = manager.Reconcile(new[] { (3u, scope) });
Equal(3u, changes[0].Epoch);
var revoked = manager.Reconcile(Array.Empty<(uint PlayerId, ScopeKey Scope)>());
Equal(2u, revoked.Single().Epoch);
var regrant = manager.Reconcile(new[] { (3u, scope) });
Equal(3u, regrant.Single().Epoch);
False(manager.Authorize(1, scope, 1));
True(manager.Authorize(3, scope, 3));
return Task.CompletedTask;
@@ -136,14 +136,10 @@ internal static class Program
private static Task TestInterestFiltering()
{
var a = Transform("00000001", "000000AA", 0, 0);
var sameCell = Transform("00000001", "000000BB", 999999, 999999);
var near = Transform("00000002", "000000AA", 1000, 1000);
var far = Transform("00000003", "000000AA", 20000, 0);
var otherWorld = Transform("00000004", "000000BB", 0, 0);
True(ProtocolValidation.StatesShareInterest(a, sameCell));
True(ProtocolValidation.StatesShareInterest(a, near));
False(ProtocolValidation.StatesShareInterest(a, far));
False(ProtocolValidation.StatesShareInterest(a, otherWorld));
True(ProtocolValidation.StatesShareInterest(a, Transform("00000001", "000000BB", 500000, 500000)));
True(ProtocolValidation.StatesShareInterest(a, Transform("00000002", "000000AA", 1000, 1000)));
False(ProtocolValidation.StatesShareInterest(a, Transform("00000003", "000000AA", 20000, 0)));
False(ProtocolValidation.StatesShareInterest(a, Transform("00000004", "000000BB", 0, 0)));
return Task.CompletedTask;
}
@@ -163,53 +159,46 @@ internal static class Program
return Task.CompletedTask;
}
private static Task TestBanStore()
private static Task TestBanPersistence()
{
var dir = TempDir();
try
{
var path = Path.Combine(dir, "bans.json");
var store = new BanStore(path);
store.Ban("127.0.0.1", "test");
var reloaded = new BanStore(path);
Equal("test", reloaded.GetBan("127.0.0.1")?.Reason);
True(reloaded.Unban("127.0.0.1"));
new BanStore(path).Ban("127.0.0.1", "test");
var loaded = new BanStore(path);
Equal("test", loaded.GetBan("127.0.0.1")?.Reason);
True(loaded.Unban("127.0.0.1"));
Equal(0, new BanStore(path).List().Count);
}
finally { Directory.Delete(dir, true); }
return Task.CompletedTask;
}
private static async Task TestAuthoritativeServer()
private static async Task TestServerOwnedIdsAndInterest()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 31001);
var b = new MemoryConnection("b", 31002);
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
True(await server.AcceptConnectionAsync(b, CancellationToken.None));
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
await Send(server, b, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
await Activate(fixture.Server, a, b);
var aId = ReadyId(a); var bId = ReadyId(b);
True(aId != bId);
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
a.Clear(); b.Clear();
await Send(server, a, Transform("00000001", "000000AA", 1, 0));
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && JsonHelpers.TryUInt32(p["playerId"], 1, uint.MaxValue, out var id) && id == aId));
await Send(fixture.Server, a, Transform("00000001", "000000AA", 1, 0));
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && PlayerId(p) == aId));
}
private static async Task TestPlayerStateRelay()
private static async Task TestDurablePlayerStateRelay()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002);
await Activate(server, a, b);
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
await Activate(fixture.Server, a, b);
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
a.Clear(); b.Clear();
await Send(server, a, new JsonObject
await Send(fixture.Server, a, new JsonObject
{
["type"] = "playerState",
["characterName"] = "Nomad",
@@ -220,62 +209,37 @@ internal static class Program
False(relay.ContainsKey("actionEvents"));
}
private static async Task TestNpcAuthorityIntegration()
private static async Task TestNpcAuthorityEnforcement()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 33001);
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
await Send(server, a, Transform("00000010", "", 0, 0, "spawn"));
await Activate(fixture.Server, a);
await Send(fixture.Server, a, Transform("00000010", "", 0, 0, "spawn"));
var authority = a.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "npcAuthority");
JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch);
True(JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch));
a.Clear();
await Send(server, a, new JsonObject
await Send(fixture.Server, a, new JsonObject
{
["type"] = "npcState",
["authorityEpoch"] = epoch,
["authorityCellId"] = "00000010",
["authorityWorldspaceId"] = "",
["npcs"] = new JsonArray(new JsonObject
{
["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "",
["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0
})
["type"] = "npcState", ["authorityEpoch"] = epoch, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
["npcs"] = new JsonArray(new JsonObject { ["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "", ["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0 })
});
var stats = server.GetCoreStats();
True(JsonHelpers.TryUInt32(stats["npcStatePacketsReceived"], 0, uint.MaxValue, out var received) && received == 1);
await Send(server, a, new JsonObject
{
["type"] = "npcState", ["authorityEpoch"] = Math.Max(1u, epoch - 1), ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
["npcs"] = new JsonArray()
});
stats = server.GetCoreStats();
True(JsonHelpers.TryUInt32(stats["npcAuthorityRejects"], 0, uint.MaxValue, out var rejects) && rejects >= 1);
Equal(1u, Stat(fixture.Server, "npcStatePacketsReceived"));
await Send(fixture.Server, a, new JsonObject { ["type"] = "npcState", ["authorityEpoch"] = epoch + 1, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "", ["npcs"] = new JsonArray() });
True(Stat(fixture.Server, "npcAuthorityRejects") >= 1);
}
private static async Task TestCombatInterest()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002);
await Activate(server, a, b);
await Activate(fixture.Server, a, b);
var bId = ReadyId(b);
await Send(server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
await Send(server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
await Send(fixture.Server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
await Send(fixture.Server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
a.Clear(); b.Clear();
await Send(server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
await Send(fixture.Server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "combatHit"));
var stats = server.GetCoreStats();
True(JsonHelpers.TryUInt32(stats["packetsRejected"], 0, uint.MaxValue, out var rejected) && rejected >= 1);
}
private static (AuthoritativeServer Server, string Dir) CreateServerFixture()
{
var dir = TempDir();
var path = Path.Combine(dir, "commonwealth-server.json");
var options = new ServerOptions { ConfigPath = path, Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 };
return (new AuthoritativeServer(options), dir);
True(Stat(fixture.Server, "packetsRejected") >= 1);
}
private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections)
@@ -287,15 +251,7 @@ internal static class Program
}
}
private static uint ReadyId(MemoryConnection connection)
{
var ready = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
True(JsonHelpers.TryUInt32(ready["playerId"], 1, uint.MaxValue, out var id));
return id;
}
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) =>
server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) => server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
private static JsonObject Transform(string cell, string world, double x, double y, string movement = "normal") => new()
{
@@ -303,6 +259,16 @@ internal static class Program
["cellId"] = cell, ["worldspaceId"] = world, ["movementType"] = movement
};
private static uint ReadyId(MemoryConnection connection)
{
var packet = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
True(JsonHelpers.TryUInt32(packet["playerId"], 1, uint.MaxValue, out var id));
return id;
}
private static uint PlayerId(JsonObject packet) => JsonHelpers.TryUInt32(packet["playerId"], 0, uint.MaxValue, out var id) ? id : 0;
private static uint Stat(AuthoritativeServer server, string name) => JsonHelpers.TryUInt32(server.GetCoreStats()[name], 0, uint.MaxValue, out var value) ? value : 0;
private static string TempDir()
{
var path = Path.Combine(Path.GetTempPath(), "co-csharp-tests-" + Guid.NewGuid().ToString("N"));
@@ -313,35 +279,43 @@ internal static class Program
private static void True(bool value) { if (!value) throw new Exception("expected true"); }
private static void False(bool value) { if (value) throw new Exception("expected false"); }
private static void NotNull(object? value) { if (value is null) throw new Exception("expected non-null"); }
private static void Equal<T>(T expected, T actual) where T : notnull { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
private static void SequenceEqual(byte[] expected, byte[] actual) { if (!expected.AsSpan().SequenceEqual(actual)) throw new Exception("byte sequences differ"); }
private static void Equal<T>(T expected, T actual) { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
private static void Throws<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception($"expected {typeof(T).Name}"); }
private sealed class ServerFixture : IAsyncDisposable
{
public ServerFixture()
{
Directory = TempDir();
Server = new AuthoritativeServer(new ServerOptions { ConfigPath = Path.Combine(Directory, "commonwealth-server.json"), Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 });
}
public string Directory { get; }
public AuthoritativeServer Server { get; }
public async ValueTask DisposeAsync()
{
await Server.DisposeAsync();
try { System.IO.Directory.Delete(Directory, true); } catch { }
}
}
private sealed class MemoryConnection : IGameConnection
{
private readonly object _gate = new();
private readonly List<JsonObject> _sent = new();
private int _closed;
public MemoryConnection(string name, int port)
{
ConnectionKey = "memory:" + name;
RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port);
}
public MemoryConnection(string name, int port) { ConnectionKey = "memory:" + name; RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port); }
public string ConnectionKey { get; }
public string TransportName => "memory";
public IPEndPoint RemoteEndpoint { get; }
public bool IsClosed => Volatile.Read(ref _closed) != 0;
public IReadOnlyList<JsonObject> SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } }
public ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
{
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
lock (_gate) _sent.Add(PacketCodec.Decode(packet.Payload));
return ValueTask.FromResult(SendOutcome.Sent);
}
public ValueTask DisconnectAsync(int reason, string debug) { Interlocked.Exchange(ref _closed, 1); return ValueTask.CompletedTask; }
public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose");
public void Clear() { lock (_gate) _sent.Clear(); }