350 lines
16 KiB
C#
350 lines
16 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 sequence", TestSnapshotSequence);
|
|
await Run("snapshot envelope", TestSnapshotEnvelope);
|
|
await Run("player state validation", TestPlayerStateValidation);
|
|
await Run("npc authority epochs", TestNpcAuthority);
|
|
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);
|
|
|
|
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);
|
|
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) }));
|
|
Throws<PacketCodecException>(() => PacketCodec.Decode("[]"u8));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static Task TestSnapshotSequence()
|
|
{
|
|
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));
|
|
var counter = new SequenceCounter();
|
|
Equal(1u, counter.Advance());
|
|
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));
|
|
Equal<string?>(null, error);
|
|
Equal("transform", envelope.PacketType);
|
|
Equal(7u, envelope.Sequence);
|
|
SequenceEqual(payload, envelope.Payload);
|
|
False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static Task TestPlayerStateValidation()
|
|
{
|
|
var state = 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(state);
|
|
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));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static Task TestNpcAuthority()
|
|
{
|
|
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);
|
|
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);
|
|
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);
|
|
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));
|
|
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 TestBanStore()
|
|
{
|
|
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"));
|
|
Equal(0, new BanStore(path).List().Count);
|
|
}
|
|
finally { Directory.Delete(dir, true); }
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static async Task TestAuthoritativeServer()
|
|
{
|
|
var fixture = CreateServerFixture();
|
|
await using var server = fixture.Server;
|
|
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 });
|
|
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"));
|
|
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));
|
|
}
|
|
|
|
private static async Task TestPlayerStateRelay()
|
|
{
|
|
var fixture = CreateServerFixture();
|
|
await using var server = fixture.Server;
|
|
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"));
|
|
a.Clear(); b.Clear();
|
|
await Send(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 TestNpcAuthorityIntegration()
|
|
{
|
|
var fixture = CreateServerFixture();
|
|
await using var server = fixture.Server;
|
|
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"));
|
|
var authority = a.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "npcAuthority");
|
|
JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch);
|
|
a.Clear();
|
|
await Send(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
|
|
})
|
|
});
|
|
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);
|
|
}
|
|
|
|
private static async Task TestCombatInterest()
|
|
{
|
|
var fixture = CreateServerFixture();
|
|
await using var server = fixture.Server;
|
|
var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002);
|
|
await Activate(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"));
|
|
a.Clear(); b.Clear();
|
|
await Send(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);
|
|
}
|
|
|
|
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 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 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 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) 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 Throws<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception($"expected {typeof(T).Name}"); }
|
|
|
|
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(); }
|
|
}
|
|
}
|