Files
Commonwealth-Online-Server/server/tests/Program.cs
T

324 lines
15 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using CommonwealthOnline.Server;
namespace CommonwealthOnline.Server.Tests;
internal static class Program
{
private static int _passed;
private static int _failed;
public static async Task<int> Main()
{
await Run("transport policy", TestTransportPolicy);
await Run("packet codec", TestPacketCodec);
await Run("snapshot sequencing", TestSnapshotSequencing);
await Run("snapshot envelope", TestSnapshotEnvelope);
await Run("player state validation", TestPlayerStateValidation);
await Run("npc authority epochs", TestNpcAuthorityEpochs);
await Run("interest filtering", TestInterestFiltering);
await Run("config compatibility", TestConfigCompatibility);
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;
}
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 Task TestTransportPolicy()
{
Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("transform"));
Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("npcState"));
Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("playerState"));
Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("futureControl"));
return Task.CompletedTask;
}
private static Task TestPacketCodec()
{
var packet = new JsonObject { ["type"] = "playerState", ["characterName"] = "Nomad" };
var encoded = PacketCodec.Encode(packet);
Equal("playerState", encoded.PacketType);
Equal(Delivery.ReliableOrdered, encoded.Delivery);
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 TestSnapshotSequencing()
{
var window = new SequenceWindow();
True(window.Accept(1));
True(window.Accept(3));
False(window.Accept(2));
False(window.Accept(3));
True(SequenceWindow.IsNewer(1, uint.MaxValue));
False(SequenceWindow.IsNewer(uint.MaxValue, 1));
return Task.CompletedTask;
}
private static Task TestSnapshotEnvelope()
{
var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}");
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);
True(payload.AsSpan().SequenceEqual(envelope.Payload));
False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _));
return Task.CompletedTask;
}
private static Task TestPlayerStateValidation()
{
var packet = new JsonObject
{
["type"] = "playerState",
["characterName"] = "Nomad",
["equippedItems"] = new JsonArray(new JsonObject { ["slot"] = "RightHand", ["formId"] = "ABC" }),
["appearance"] = new JsonObject
{
["version"] = 4,
["raceFormId"] = "13746",
["hairColorFormId"] = "123",
["facialHairColorFormId"] = "124",
["complexionFormId"] = "125",
["height"] = 1.0,
["isFemale"] = false,
["morphWeight"] = new JsonObject { ["thin"] = 0.0, ["muscular"] = 0.0, ["large"] = 0.0 },
["bodyTintColor"] = new JsonObject { ["r"] = 255, ["g"] = 255, ["b"] = 255, ["a"] = 255 }
},
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
};
var normalized = ProtocolValidation.NormalizePlayerState(packet);
NotNull(normalized);
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 TestNpcAuthorityEpochs()
{
var manager = new NpcAuthorityManager();
var scope = new ScopeKey("00000001", "");
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));
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;
}
private static Task TestInterestFiltering()
{
var a = Transform("00000001", "000000AA", 0, 0);
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;
}
private static Task TestConfigCompatibility()
{
var dir = TempDir();
try
{
var path = Path.Combine(dir, "commonwealth-server.json");
File.WriteAllText(path, "{\"host\":\"0.0.0.0\",\"port\":7777,\"max_players\":16,\"admin_port\":7779,\"enable_gns_transport\":\"false\"}");
var options = ServerOptions.Load(path);
False(options.EnableGnsTransport);
Equal(7777, options.Port);
Equal(0, options.Validate().Count);
}
finally { Directory.Delete(dir, true); }
return Task.CompletedTask;
}
private static Task TestBanPersistence()
{
var dir = TempDir();
try
{
var path = Path.Combine(dir, "bans.json");
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 TestServerOwnedIdsAndInterest()
{
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 31001);
var b = new MemoryConnection("b", 31002);
await Activate(fixture.Server, a, b);
var aId = ReadyId(a); var bId = ReadyId(b);
True(aId != bId);
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(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 TestDurablePlayerStateRelay()
{
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002);
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(fixture.Server, a, new JsonObject
{
["type"] = "playerState",
["characterName"] = "Nomad",
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
});
var relay = b.SentPackets.Single(p => JsonHelpers.String(p["type"]) == "playerState");
Equal("Nomad", JsonHelpers.String(relay["characterName"]));
False(relay.ContainsKey("actionEvents"));
}
private static async Task TestNpcAuthorityEnforcement()
{
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 33001);
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");
True(JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch));
a.Clear();
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 })
});
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()
{
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002);
await Activate(fixture.Server, a, b);
var bId = ReadyId(b);
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(fixture.Server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "combatHit"));
True(Stat(fixture.Server, "packetsRejected") >= 1);
}
private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections)
{
foreach (var connection in connections)
{
True(await server.AcceptConnectionAsync(connection, CancellationToken.None));
await Send(server, connection, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
}
}
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()
{
["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = 0.0, ["angleZ"] = 0.0,
["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"));
Directory.CreateDirectory(path);
return path;
}
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) { 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 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(); }
}
}