using System.Net.Sockets; using MSCO.Server; // Protokoll-Tests gegen den echten Server-Code: startet einen Server offline (ohne Master, ohne Lizenz) auf // Port 17777 und spielt mehrere Clients durch - Beitreten, Ping, Weltzustand, Chat, Admin-Rechte, kaputte Daten. // Ausführen im Server-Ordner: dotnet run --project Tests const int Port = 17777; const string AdminId = "76561198910227878"; string dir = Path.Combine(Path.GetTempPath(), "msco-servertest-" + Guid.NewGuid().ToString("N")[..8]); Directory.CreateDirectory(Path.Combine(dir, "save")); // Platzhalter-Spielstand (der Inhalt ist für die Protokoll-Tests egal) foreach (var name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }) File.WriteAllText(Path.Combine(dir, "save", name), "test " + name); string configPath = Path.Combine(dir, "server.json"); var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } }; config.Save(configPath); var server = new GameServer(config, dir, configPath, "Tester", offline: true); if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; } var serverThread = new Thread(server.Run) { IsBackground = true }; serverThread.Start(); Thread.Sleep(500); int failed = 0, passed = 0; void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.WriteLine((ok ? " ok " : " FAIL ") + what); } try { Console.WriteLine("== Join"); var alice = await TestClient.Join("Alice", 0, null); Check(alice.Welcome != null, "Alice gets Welcome"); Check(alice.Id > 0, "Alice has id " + alice.Id); Check(alice.AdminStatus == false, "Alice is not admin"); Check(alice.SaveFiles == 5, "Alice received save files (" + alice.SaveFiles + ")"); Check(alice.GotStartGame, "Alice got StartGame"); Console.WriteLine("== Ping"); alice.Send(MsgType.Ping, BitConverter.GetBytes(12.5f)); var pong = await alice.WaitFor(MsgType.Pong); Check(pong != null && BitConverter.ToSingle(pong, 0) == 12.5f, "Ping is echoed as Pong with the same timestamp"); Console.WriteLine("== Ready / snapshot"); alice.Send(MsgType.Ready, Array.Empty()); var snap = await alice.WaitFor(MsgType.WorldSnapshot); Check(snap != null, "WorldSnapshot received"); var s0 = Snapshot.Parse(snap); Check(s0.Ok, "Snapshot parses completely (" + s0.Info + ")"); Console.WriteLine("== Second player, chat"); var bob = await TestClient.Join("Bob", 0, null); Check(bob.Welcome != null, "Bob joins"); var joined = await alice.WaitFor(MsgType.PlayerJoined); Check(joined != null, "Alice is told that Bob joined"); alice.Send(MsgType.Chat, Wire.Build(w => w.Write("hello bob"))); var chat = await bob.WaitFor(MsgType.Chat); Check(chat != null && new BinaryReader(new MemoryStream(chat)).ReadInt32() == alice.Id, "Bob receives Alice's chat with her id"); Check(chat != null && ReadStringAfterSender(chat) == "hello bob", "Chat text arrives unchanged"); Console.WriteLine("== Object states (valid / invalid)"); alice.Send(MsgType.ObjStates, ObjStates((111, 10, 1, 20, 0, 0, 0, 1), (222, float.NaN, 0, 0, 0, 0, 0, 1), (333, 0, -5000, 0, 0, 0, 0, 1), (444, 1, 1, 1, 0, 0, 0, 0))); var relay = await bob.WaitFor(MsgType.ObjStates); Check(relay != null, "Object states are relayed to Bob"); alice.Send(MsgType.VarSync, Wire.Build(w => { w.Write(2); w.Write(42); w.Write((byte)1); w.Write(3.5f); w.Write(43); w.Write((byte)3); w.Write(true); })); await bob.WaitFor(MsgType.VarSync); var carol = await TestClient.Join("Carol", 0, null); carol.Send(MsgType.Ready, Array.Empty()); var snap2 = Snapshot.Parse(await carol.WaitFor(MsgType.WorldSnapshot)); Check(snap2.ObjectIds.Contains(111), "Valid object pose is stored"); Check(!snap2.ObjectIds.Contains(222), "NaN pose is rejected"); Check(!snap2.ObjectIds.Contains(333), "Pose far below the map is rejected"); Check(!snap2.ObjectIds.Contains(444), "Invalid quaternion is rejected"); Check(snap2.Vars == 2, "Vehicle values are stored and sent to new players (" + snap2.Vars + ")"); Console.WriteLine("== Server full"); var eve = await TestClient.Join("Eve", 0, null); var dave = await TestClient.Join("Dave", 0, null, expectReject: true); Check(dave.RejectReason != null && dave.RejectReason.Contains("full"), "Player over the limit is rejected: " + dave.RejectReason); eve.Close(); Console.WriteLine("== Admin rights"); alice.Send(MsgType.AdminListRequest, Array.Empty()); var notAdmin = await alice.WaitFor(MsgType.AdminResult); Check(notAdmin != null && ReadString(notAdmin).Contains("not an admin"), "Non-admin cannot open the admin list"); alice.Send(MsgType.AdminAction, Wire.Build(w => { w.Write((byte)1); w.Write(bob.Id); w.Write("x"); })); var kickTry = await alice.WaitFor(MsgType.AdminResult); Check(kickTry != null && ReadString(kickTry).Contains("not an admin"), "Non-admin cannot kick"); carol.Close(); await Task.Delay(300); // Jemand gibt sich mit der SteamID des Admins aus (ohne Steam Web API Key wird das Ticket nicht geprüft) var faker = await TestClient.Join("Faker", ulong.Parse(AdminId), new byte[] { 1, 2, 3, 4 }); Check(faker.Welcome != null, "Player with a claimed SteamID joins (tickets not verified without key)"); Check(faker.AdminStatus == false, "Claimed (unverified) SteamID must NOT get admin rights"); var faker2 = await TestClient.Join("Faker2", ulong.Parse(AdminId), new byte[] { 9 }, expectReject: true); Check(faker2.RejectReason != null && faker2.RejectReason.Contains("already connected"), "Second player with the same unverified SteamID cannot push out the first: " + faker2.RejectReason); Check(!faker.IsClosedByServer(), "The first player stays connected"); faker.Close(); await Task.Delay(300); Console.WriteLine("== Robustness"); var evil = await TestClient.Join("Evil", 0, null); evil.SendRaw(BitConverter.GetBytes(5 * 1024 * 1024)); // Paket, das 5 MB groß sein will await Task.Delay(500); Check(evil.IsClosedByServer(), "Oversized packet closes only that connection"); var bob2 = await TestClient.Join("Bob2", 0, null); Check(bob2.Welcome != null, "Server still accepts players afterwards"); bob2.Send(MsgType.VarSync, new byte[] { 1, 2 }); // kaputte Nachricht await Task.Delay(500); Check(bob2.IsClosedByServer(), "Malformed message closes only that connection"); alice.Send(MsgType.Ping, BitConverter.GetBytes(1f)); Check(await alice.WaitFor(MsgType.Pong) != null, "Other players are not affected"); Console.WriteLine("== Chat limits"); alice.Send(MsgType.Chat, Wire.Build(w => w.Write(new string('x', 5000)))); var longChat = await bob.WaitFor(MsgType.Chat, 800); Check(longChat == null, "Huge chat messages are not relayed"); } catch (Exception e) { failed++; Console.WriteLine(" FAIL exception: " + e); } Console.WriteLine($"\n{passed} passed, {failed} failed"); server.EnqueueCommand("stop"); Thread.Sleep(300); try { Directory.Delete(dir, true); } catch { } return failed == 0 ? 0 : 1; static string ReadString(byte[] p) => new BinaryReader(new MemoryStream(p)).ReadString(); static string ReadStringAfterSender(byte[] p) { var r = new BinaryReader(new MemoryStream(p)); r.ReadInt32(); return r.ReadString(); } static byte[] ObjStates(params (int id, float x, float y, float z, float qx, float qy, float qz, float qw)[] items) => Wire.Build(w => { w.Write(items.Length); foreach (var i in items) { w.Write(i.id); w.Write(i.x); w.Write(i.y); w.Write(i.z); w.Write(i.qx); w.Write(i.qy); w.Write(i.qz); w.Write(i.qw); w.Write(0f); w.Write(0f); w.Write(0f); } }); sealed class Snapshot { public bool Ok; public string Info; public HashSet ObjectIds = new(); public int Vars = -1; public static Snapshot Parse(byte[] data) { var s = new Snapshot(); if (data == null) { s.Info = "no data"; return s; } try { var r = new BinaryReader(new MemoryStream(data)); if (r.ReadBoolean()) { r.ReadSingle(); r.ReadSingle(); r.ReadSingle(); } int objects = r.ReadInt32(); for (int i = 0; i < objects; i++) { s.ObjectIds.Add(r.ReadInt32()); for (int k = 0; k < 7; k++) r.ReadSingle(); } int events = r.ReadInt32(); for (int i = 0; i < events; i++) r.ReadBytes(r.ReadInt32()); float money = r.ReadSingle(); int doors = r.ReadInt32(); for (int i = 0; i < doors; i++) { r.ReadString(); r.ReadString(); } s.Vars = r.ReadInt32(); for (int i = 0; i < s.Vars; i++) { r.ReadInt32(); byte t = r.ReadByte(); r.ReadBytes(t == 3 ? 1 : 4); } s.Ok = r.BaseStream.Position == r.BaseStream.Length; s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars"; } catch (Exception e) { s.Info = "parse error " + e.Message; } return s; } } sealed class TestClient { const int Port = 17777; TcpClient tcp; NetworkStream stream; readonly List<(MsgType type, byte[] payload)> inbox = new(); readonly object gate = new(); bool closed; public byte[] Welcome; public int Id; public bool? AdminStatus; public int SaveFiles; public bool GotStartGame; public string RejectReason; public static async Task Join(string name, ulong steamId, byte[] ticket, bool expectReject = false) { var c = new TestClient { tcp = new TcpClient() }; await c.tcp.ConnectAsync("127.0.0.1", Port); c.stream = c.tcp.GetStream(); _ = Task.Run(c.ReadLoop); c.Send(MsgType.Hello, Wire.Build(w => { w.Write("2.6"); w.Write(name); w.Write(steamId); w.Write(ticket?.Length ?? 0); if (ticket != null) w.Write(ticket); })); if (expectReject) { var rej = await c.WaitFor(MsgType.Reject); if (rej != null) c.RejectReason = new BinaryReader(new MemoryStream(rej)).ReadString(); return c; } c.Welcome = await c.WaitFor(MsgType.Welcome); if (c.Welcome != null) c.Id = BitConverter.ToInt32(c.Welcome, 0); var admin = await c.WaitFor(MsgType.AdminStatus); if (admin != null) c.AdminStatus = admin[0] != 0; await c.WaitFor(MsgType.SaveBegin); while (await c.WaitFor(MsgType.SaveFile, 1500) != null) c.SaveFiles++; c.GotStartGame = await c.WaitFor(MsgType.StartGame, 1500) != null || c.SaveFiles > 0; return c; } async Task ReadLoop() { var header = new byte[4]; try { while (true) { await ReadExact(header, 4); int len = BitConverter.ToInt32(header, 0); var body = new byte[len]; await ReadExact(body, len); lock (gate) inbox.Add(((MsgType)body[0], body[1..])); } } catch { closed = true; } } async Task ReadExact(byte[] buf, int count) { int off = 0; while (off < count) { int n = await stream.ReadAsync(buf.AsMemory(off, count - off)); if (n <= 0) throw new IOException("closed"); off += n; } } public void Send(MsgType type, byte[] payload) { byte[] frame = Wire.Frame(type, payload); try { stream.Write(frame, 0, frame.Length); } catch { closed = true; } } public void SendRaw(byte[] data) { try { stream.Write(data, 0, data.Length); stream.Write(new byte[16], 0, 16); } catch { closed = true; } } public async Task WaitFor(MsgType type, int timeoutMs = 3000) { var end = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < end) { lock (gate) { int i = inbox.FindIndex(m => m.type == type); if (i >= 0) { var p = inbox[i].payload; inbox.RemoveAt(i); return p; } } await Task.Delay(20); } return null; } public bool IsClosedByServer() => closed; public void Close() { try { tcp.Close(); } catch { } } }