Add C# dedicated server replacement

This commit is contained in:
Nomads_Reach
2026-08-16 02:23:04 -04:00
parent 0024436ce8
commit ddadf87423
17 changed files with 3516 additions and 0 deletions
+349
View File
@@ -0,0 +1,349 @@
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(); }
}
}