Files
MSCO-Dedicated-Server/Tests/Program.cs
T
MrMahoandClaude Opus 5.5 d946c95198 Server 2.10.3: browse the MSCO mod list in the web panel
Mods -> MSCO mod list: search the approved mods from msco.mbm.wtf/mods and add or
update one with a click. The server lists and downloads only from the official
keymaster (never from a URL in the answer), checks the SHA-256 and stores it in
data/mods like an uploaded mod. Admins only. 6 new tests (154 pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:08:37 +02:00

890 lines
54 KiB
C#

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);
// Host wie im echten Betrieb: besitzt Port + Web-Panel (gleicher Port wie das Spiel) und startet den Gameserver
var host = new ServerHost(config, dir, configPath, offline: true)
{
ConfigureServer = s => s.SaveUploadTimeoutSeconds = 1 // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
};
var hostThread = new Thread(() => host.Run()) { IsBackground = true };
hostThread.Start();
for (int i = 0; i < 100 && host.State != HostState.Running; i++) Thread.Sleep(50);
if (host.State != HostState.Running) { Console.WriteLine("FAIL: server did not start: " + host.LastError); return 1; }
GameServer Srv() => host.Server;
async Task WaitForState(HostState state) { for (int i = 0; i < 100 && host.State != state; i++) await Task.Delay(50); }
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<byte>());
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);
// Jobs/Laden und Schalter (Licht an, Motor läuft) werden gespeichert, damit später Beitretende sie haben
alice.Send(MsgType.WorldVars, Wire.Build(w => { w.Write(2); w.Write(7); w.Write((byte)2); w.Write(5); w.Write(8); w.Write((byte)3); w.Write(true); }));
await bob.WaitFor(MsgType.WorldVars);
alice.Send(MsgType.VehicleControls, Wire.Build(w => { w.Write(1); w.Write(99); w.Write("Lights"); }));
await bob.WaitFor(MsgType.VehicleControls);
var carol = await TestClient.Join("Carol", 0, null);
carol.Send(MsgType.Ready, Array.Empty<byte>());
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 + ")");
Check(snap2.JobVars == 2 && snap2.Controls.TryGetValue(99, out string lightState) && lightState == "Lights",
"Job/shop values and switch states (lights, engine) are stored for players joining later");
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<byte>());
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");
Console.WriteLine("== Bought / consumed items");
alice.Send(MsgType.ItemAppeared, Wire.Build(w => { w.Write(4242); w.Write("beer case(itemx)"); w.Write(1f); w.Write(2f); w.Write(3f); w.Write(0f); w.Write(0f); w.Write(0f); w.Write(1f); }));
Check(await bob.WaitFor(MsgType.ItemAppeared) != null, "Bought item is relayed to the other players");
alice.Send(MsgType.ObjDestroy, Wire.Build(w => w.Write(777)));
Check(await bob.WaitFor(MsgType.ObjDestroy) != null, "Consumed item is relayed to the other players");
alice.Send(MsgType.VehicleDamage, Wire.Build(w => { w.Write(1); w.Write(10); w.Write(1); w.Write(3); w.Write(0.1f); w.Write(0.2f); w.Write(0.3f); }));
Check(await bob.WaitFor(MsgType.VehicleDamage) != null, "Crash damage is relayed");
alice.Send(MsgType.WorldVars, Wire.Build(w => { w.Write(1); w.Write(5); w.Write((byte)3); w.Write(true); }));
Check(await bob.WaitFor(MsgType.WorldVars) != null, "Job/shop values are relayed");
alice.Send(MsgType.HingeStates, Wire.Build(w => { w.Write(0); }));
Check(await bob.WaitFor(MsgType.HingeStates) != null, "Car door angles are relayed");
alice.Send(MsgType.VehicleControls, Wire.Build(w => { w.Write(1); w.Write(123); w.Write("Lights"); }));
Check(await bob.WaitFor(MsgType.VehicleControls) != null, "Vehicle controls (ignition, lights ...) are relayed");
byte[] trafficPayload = Wire.Build(w => { w.Write(0); });
bob.Send(MsgType.TrafficStates, trafficPayload);
Check(await alice.WaitFor(MsgType.TrafficStates, 800) == null, "Traffic from a player who is not the world authority is ignored");
alice.Send(MsgType.TrafficStates, trafficPayload);
Check(await bob.WaitFor(MsgType.TrafficStates) != null, "Traffic from the world authority is relayed");
for (int i = 0; i < 40; i++) { int id = 1000 + i; bob.Send(MsgType.ObjDestroy, Wire.Build(w => w.Write(id))); }
await Task.Delay(200);
var late = await TestClient.Join("Late", 0, null);
late.Send(MsgType.Ready, Array.Empty<byte>());
var lateSnap = Snapshot.Parse(await late.WaitFor(MsgType.WorldSnapshot));
Check(lateSnap.Ok && lateSnap.Appeared == 1 && lateSnap.Destroyed.Contains(777),
"Players joining later get bought and consumed items: " + lateSnap.Info);
Check(lateSnap.Destroyed.Count <= 21, "Mass 'consumed' reports (e.g. scene unload) are capped: " + lateSnap.Destroyed.Count + " stored");
late.Close();
Console.WriteLine("== Own needs (hunger, thirst ...) are stored per player");
var hungry = await TestClient.Join("Hungry", 0, null);
hungry.Send(MsgType.Ready, Array.Empty<byte>());
await hungry.WaitFor(MsgType.WorldSnapshot);
Srv().EnqueueCommand("save"); // (Zeitfenster TrackPositionFrom abwarten)
await Task.Delay(8500);
hungry.Send(MsgType.PlayerNeeds, Wire.Build(w => { w.Write(2); w.Write("PlayerHunger"); w.Write(77f); w.Write("PlayerThirst"); w.Write(12.5f); }));
await Task.Delay(300);
hungry.Close();
await Task.Delay(500);
var hungry2 = await TestClient.Join("Hungry", 0, null);
hungry2.Send(MsgType.Ready, Array.Empty<byte>());
var needsSnap = Snapshot.Parse(await hungry2.WaitFor(MsgType.WorldSnapshot));
Check(needsSnap.Ok && needsSnap.Needs.TryGetValue("PlayerHunger", out float hunger) && Math.Abs(hunger - 77f) < 0.01f,
"Rejoining player gets their own needs back: " + string.Join(", ", needsSnap.Needs.Select(kv => kv.Key + "=" + kv.Value)));
var otherSnapClient = await TestClient.Join("NotHungry", 0, null);
otherSnapClient.Send(MsgType.Ready, Array.Empty<byte>());
var otherSnap = Snapshot.Parse(await otherSnapClient.WaitFor(MsgType.WorldSnapshot));
Check(otherSnap.Ok && otherSnap.Needs.Count == 0, "Other players do not get someone else's needs");
hungry2.Close();
otherSnapClient.Close();
await Task.Delay(300);
Console.WriteLine("== Last position (spawn)");
var pos1 = await TestClient.Join("Walker", 0, null);
pos1.Send(MsgType.Player, PlayerPos(-15.6f, -0.3f, 7f)); // Startpunkt des Spielstands (Toilette) - vor Ready
pos1.Send(MsgType.Ready, Array.Empty<byte>());
Check(Snapshot.Parse(await pos1.WaitFor(MsgType.WorldSnapshot)).Spawn == null, "Position before joining is not stored (no spawn yet)");
pos1.Send(MsgType.Player, PlayerPos(-15.6f, -0.3f, 7f)); // direkt nach Ready: noch nicht übernehmen
await Task.Delay(8500);
pos1.Send(MsgType.Player, PlayerPos(250f, 3f, -120f)); // echte Position nach dem Umsetzen
await Task.Delay(300);
pos1.Send(MsgType.Player, PlayerPos(2.7e9f, 9.4e10f, -8.5e8f)); // weggeschleudert - darf nicht gespeichert werden
await Task.Delay(300);
pos1.Close();
await Task.Delay(500);
var pos2 = await TestClient.Join("Walker", 0, null);
pos2.Send(MsgType.Ready, Array.Empty<byte>());
var spawn = Snapshot.Parse(await pos2.WaitFor(MsgType.WorldSnapshot)).Spawn;
Check(spawn != null && Math.Abs(spawn[0] - 250f) < 0.01f && Math.Abs(spawn[2] + 120f) < 0.01f,
"Rejoining returns the last real position, not the save's start point or a flung-away one: " + (spawn == null ? "none" : string.Join(", ", spawn)));
pos2.Close();
Console.WriteLine("== Admin: spawn items / unban");
var noAdmin = await TestClient.Join("NoAdmin", 0, null);
var watcher = await TestClient.Join("Watcher", 0, null);
noAdmin.Send(MsgType.SpawnItem, Wire.Build(w => { w.Write("beer case"); w.Write(1f); w.Write(2f); w.Write(3f); w.Write(0f); w.Write(0f); w.Write(0f); w.Write(1f); }));
Check(await watcher.WaitFor(MsgType.SpawnItem, 800) == null, "Non-admin cannot spawn items (not relayed)");
watcher.Send(MsgType.Ready, Array.Empty<byte>());
Check(Snapshot.Parse(await watcher.WaitFor(MsgType.WorldSnapshot)).Spawns == 0, "Non-admin spawn is not stored");
Srv().EnqueueCommand("ban " + noAdmin.Id + " test");
await Task.Delay(500);
var bannedTry = await TestClient.Join("NoAdmin", 0, null, expectReject: true);
Check(bannedTry.RejectReason != null && bannedTry.RejectReason.Contains("banned"), "Banned player cannot join: " + bannedTry.RejectReason);
Srv().EnqueueCommand("unban name:noadmin");
await Task.Delay(500);
var unbanned = await TestClient.Join("NoAdmin", 0, null);
Check(unbanned.Welcome != null, "After unban the player can join again");
unbanned.Close();
watcher.Close();
await Task.Delay(300);
Console.WriteLine("== Kick reason arrives while the player keeps sending");
var spammer = await TestClient.Join("Spammer", 0, null);
var spamming = true;
var spamTask = Task.Run(async () => { while (spamming) { spammer.Send(MsgType.Player, PlayerPos(1f, 2f, 3f)); await Task.Delay(5); } });
await Task.Delay(300);
Srv().EnqueueCommand("kick " + spammer.Id + " testing");
var kickMsg = await spammer.WaitFor(MsgType.Reject, 3000);
spamming = false;
await spamTask;
Check(kickMsg != null && new BinaryReader(new MemoryStream(kickMsg)).ReadString().Contains("testing"),
"Kicked player receives the reason even while sending data (no hard reset)");
spammer.Close();
await Task.Delay(300);
Console.WriteLine("== Mods (data/mods)");
var noModsClient = await TestClient.Join("NoMods", 0, null);
Check(await noModsClient.WaitFor(MsgType.ModList, 300) == null, "Without mods the server sends no mod list");
noModsClient.Close();
Directory.CreateDirectory(Path.Combine(dir, "mods"));
var modBytes = new byte[150_000];
new Random(7).NextBytes(modBytes);
modBytes[0] = 0x4d; modBytes[1] = 0x5a; // "MZ"
File.WriteAllBytes(Path.Combine(dir, "mods", "testmod.dll"), modBytes);
File.WriteAllText(Path.Combine(dir, "mods", "bad name!.dll"), "MZ nope");
Srv().EnqueueCommand("mods reload");
await Task.Delay(500);
var modder = await TestClient.Join("Modder", 0, null);
var modList = await modder.WaitFor(MsgType.ModList, 1000);
Check(modList != null, "Players get the server's mod list when they join");
if (modList != null)
{
var mr = new BinaryReader(new MemoryStream(modList));
int modCount = mr.ReadInt32();
string modId = mr.ReadString(), modName = mr.ReadString(), modVersion = mr.ReadString(), modSha = mr.ReadString();
int modSize = mr.ReadInt32();
Check(modCount == 1 && modId == "testmod" && modSize == modBytes.Length, $"Mod list has the valid mod only ({modCount}: {modId}, {modSize} bytes)");
modder.Send(MsgType.ModFileRequest, Wire.Build(w => w.Write("testmod")));
var modData = new byte[modSize];
int received = 0;
byte[] part;
while ((part = await modder.WaitFor(MsgType.ModFilePart, 1500)) != null)
{
var pr = new BinaryReader(new MemoryStream(part));
pr.ReadString(); int total = pr.ReadInt32(), off = pr.ReadInt32(), len = pr.ReadInt32();
pr.ReadBytes(len).CopyTo(modData, off);
received += len;
if (received >= total) break;
}
Check(await modder.WaitFor(MsgType.ModFileDone, 1500) != null, "Mod download ends with ModFileDone");
string gotSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(modData)).ToLowerInvariant();
Check(received == modSize && gotSha == modSha, "Downloaded mod matches the checksum from the list");
modder.Send(MsgType.ModFileRequest, Wire.Build(w => w.Write("testmod")));
Check(await modder.WaitFor(MsgType.ModFilePart, 600) == null, "Each mod is sent only once per connection");
modder.Send(MsgType.ModFileRequest, Wire.Build(w => w.Write("../server")));
Check(await modder.WaitFor(MsgType.ModFilePart, 400) == null, "Unknown mod ids are ignored");
}
var modPeer = await TestClient.Join("ModPeer", 0, null);
modder.Send(MsgType.ModMessage, Wire.Build(w => { w.Write("hellomod"); w.Write(4); w.Write(new byte[] { 1, 2, 3, 4 }); }));
var modMsg = await modPeer.WaitFor(MsgType.ModMessage);
Check(modMsg != null && BitConverter.ToInt32(modMsg, 0) == modder.Id, "Mod messages are relayed to the other players with the sender");
modder.Send(MsgType.ModMessage, new byte[70_000]);
Check(await modPeer.WaitFor(MsgType.ModMessage, 600) == null, "Oversized mod messages are dropped");
modder.Close();
modPeer.Close();
File.Delete(Path.Combine(dir, "mods", "testmod.dll"));
File.Delete(Path.Combine(dir, "mods", "bad name!.dll"));
Srv().EnqueueCommand("mods reload");
await Task.Delay(300);
Console.WriteLine("== reset-world makes everyone reconnect");
var r1 = await TestClient.Join("Resetter", 0, null);
r1.Send(MsgType.Ready, Array.Empty<byte>());
await r1.WaitFor(MsgType.WorldSnapshot);
await Task.Delay(8500);
r1.Send(MsgType.Player, PlayerPos(950f, 1f, -653f)); // irgendwo in der alten Welt
await Task.Delay(300);
Srv().EnqueueCommand("reset-world");
var rec = await r1.WaitFor(MsgType.Reconnect);
r1.Send(MsgType.Player, PlayerPos(951f, 1f, -654f)); // kommt noch an, bevor die Verbindung zu ist
Check(rec != null && new BinaryReader(new MemoryStream(rec)).ReadString().Contains("reset"), "Players get the 'world is being reset' reconnect message");
await Task.Delay(300);
Check(r1.IsClosedByServer(), "Their connection is closed so they load the fresh world");
var rejoined = await TestClient.Join("Resetter", 0, null);
rejoined.Send(MsgType.Ready, Array.Empty<byte>());
var resetSpawn = Snapshot.Parse(await rejoined.WaitFor(MsgType.WorldSnapshot)).Spawn;
Check(resetSpawn == null, "After a reset nobody spawns at their old position (also not from late position updates): " +
(resetSpawn == null ? "none" : string.Join(", ", resetSpawn)));
rejoined.Close();
await Task.Delay(300);
Console.WriteLine("== Joining gets the CURRENT save game from a player in the game");
var inGame = await TestClient.Join("InGame", 0, null);
inGame.Send(MsgType.Ready, Array.Empty<byte>());
await inGame.WaitFor(MsgType.WorldSnapshot);
inGame.Send(MsgType.FsmEvent, Wire.Build(w => { w.Write("SATSUMA|bolt#0"); w.Write("Screw"); w.Write("TIGHTEN"); w.Write("Tight"); w.Write(0f); w.Write(0f); w.Write(0f); }));
await Task.Delay(200);
var joinTask = TestClient.Join("Newcomer", 0, null);
var request = await inGame.WaitFor(MsgType.SaveRequest, 5000);
Check(request != null, "Server asks the player in the game for the current save");
if (request != null)
{
int reqId = BitConverter.ToInt32(request, 0);
byte[] fresh = System.Text.Encoding.UTF8.GetBytes(new string('S', 300_000) + "CURRENT-SAVE");
for (int off = 0; off < fresh.Length; off += 100_000)
{
int len = Math.Min(100_000, fresh.Length - off);
inGame.Send(MsgType.SaveUploadPart, Wire.Build(w => { w.Write(reqId); w.Write("defaultES2File.txt"); w.Write(fresh.Length); w.Write(off); w.Write(len); w.Write(fresh, off, len); }));
}
inGame.Send(MsgType.SaveUploadDone, Wire.Build(w => { w.Write(reqId); w.Write(1); }));
}
var newcomer = await joinTask;
Check(newcomer.Files.TryGetValue("defaultES2File.txt", out var got) && System.Text.Encoding.UTF8.GetString(got).EndsWith("CURRENT-SAVE"),
"Newcomer receives exactly the uploaded current save (" + (got?.Length ?? 0) + " bytes, sent in parts)");
newcomer.Send(MsgType.Ready, Array.Empty<byte>());
var fresh2 = Snapshot.Parse(await newcomer.WaitFor(MsgType.WorldSnapshot));
Check(fresh2.Ok && fresh2.Info.Contains("0 events"), "Replayed actions are cleared - they are in the save now (" + fresh2.Info + ")");
Check(Directory.Exists(Path.Combine(dir, "save_backups")) && Directory.GetDirectories(Path.Combine(dir, "save_backups")).Length > 0,
"The previous save game is kept as a backup");
Console.WriteLine("== World admin: reset / backup / restore");
newcomer.Send(MsgType.AdminAction, Wire.Build(w => { w.Write((byte)5); w.Write(-1); w.Write(""); }));
var resetTry = await newcomer.WaitFor(MsgType.AdminResult);
Check(resetTry != null && ReadString(resetTry).Contains("not an admin") && !newcomer.IsClosedByServer(), "Non-admin cannot reset the world");
newcomer.Send(MsgType.AdminAction, Wire.Build(w => { w.Write((byte)7); w.Write(-1); w.Write("1"); }));
var restoreTry = await newcomer.WaitFor(MsgType.AdminResult);
Check(restoreTry != null && ReadString(restoreTry).Contains("not an admin"), "Non-admin cannot restore a backup");
string backupsDir = Path.Combine(dir, "save_backups");
int before = Directory.GetDirectories(backupsDir).Length;
await Task.Delay(1100); // Backups heißen nach der Sekunde
Srv().EnqueueCommand("backup");
await Task.Delay(500);
Check(Directory.GetDirectories(backupsDir).Length == Math.Min(before + 1, 5), "'backup' creates a new backup of the current save");
// Aktuellen Spielstand verändern, dann das neueste Backup (= CURRENT-SAVE) zurückholen
File.WriteAllText(Path.Combine(dir, "save", "defaultES2File.txt"), "BROKEN");
Srv().EnqueueCommand("restore-save 1");
var restoreMsg = await newcomer.WaitFor(MsgType.Reconnect);
Check(restoreMsg != null, "Restoring a backup makes everyone reconnect");
Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).EndsWith("CURRENT-SAVE"), "The backup's files are back in the active save");
Console.WriteLine("== reset-world goes back to the ORIGINAL save (not the auto-uploaded one)");
var r2 = await TestClient.Join("Resetter2", 0, null);
Srv().EnqueueCommand("reset-world");
Check(await r2.WaitFor(MsgType.Reconnect) != null, "Players reconnect after the reset");
Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "test defaultES2File.txt",
"The active save is the original one again: " + File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).Length + " chars");
var afterReset = await TestClient.Join("AfterReset", 0, null);
Check(afterReset.Files.TryGetValue("defaultES2File.txt", out var orig) && System.Text.Encoding.UTF8.GetString(orig) == "test defaultES2File.txt",
"Joining players download the original save");
afterReset.Close();
inGame.Close();
newcomer.Close();
await PanelTests();
}
catch (Exception e)
{
failed++;
Console.WriteLine(" FAIL exception: " + e);
}
async Task PanelTests()
{
Console.WriteLine("== Web panel on the game port");
Check(host.Panel != null && host.Panel.SharesGamePort, "Panel started and shares the game port");
string baseUrl = $"http://127.0.0.1:{config.Port}/";
HttpClient Browser()
{
var handler = new HttpClientHandler { CookieContainer = new System.Net.CookieContainer(), UseCookies = true };
var http = new HttpClient(handler) { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(10) };
return http;
}
async Task<(int status, System.Text.Json.JsonElement json)> Call(HttpClient http, string method, string path, object body = null, bool csrf = true)
{
var req = new HttpRequestMessage(new HttpMethod(method), path);
if (csrf) req.Headers.Add("X-MSCO-Panel", "1");
if (body != null) req.Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
string text = await res.Content.ReadAsStringAsync();
System.Text.Json.JsonElement json = default;
try { json = System.Text.Json.JsonDocument.Parse(text).RootElement; } catch { }
return ((int)res.StatusCode, json);
}
var owner = Browser();
var page = await owner.GetAsync("");
Check(page.IsSuccessStatusCode && (await page.Content.ReadAsStringAsync()).Contains("MSCO Server Panel"),
"Browser gets the panel page on the game port");
Check(page.Headers.Contains("Content-Security-Policy"), "Security headers are set");
var session = await Call(owner, "GET", "api/session");
Check(session.status == 200 && session.json.GetProperty("needsSetup").GetBoolean(), "Fresh panel asks for setup");
Check((await Call(owner, "GET", "api/players")).status == 401, "API needs a login");
Check((await Call(owner, "POST", "api/setup", new { code = "WRONG-CODE0", username = "owner", password = "supersecret1" })).status == 403,
"Wrong setup code is rejected");
Check((await Call(owner, "POST", "api/setup", new { code = host.Panel.SetupCode, username = "owner", password = "supersecret1" }, csrf: false)).status == 403,
"Requests without the panel header are rejected (CSRF)");
var setup = await Call(owner, "POST", "api/setup", new { code = host.Panel.SetupCode, username = "owner", password = "supersecret1" });
Check(setup.status == 200 && host.Panel.SetupCode == null, "Setup with the console code creates the owner and logs in");
Check(File.Exists(Path.Combine(dir, "panel_users.json")) && !File.ReadAllText(Path.Combine(dir, "panel_users.json")).Contains("supersecret1"),
"Users are stored with a password hash, not the password");
// Spiel läuft auf demselben Port weiter
var gamer = await TestClient.Join("PanelTarget", 0, null);
Check(gamer.Welcome != null, "Game clients still join on the same port");
var players = await Call(owner, "GET", "api/players");
bool listed = players.status == 200 && players.json.EnumerateArray().Any(p => p.GetProperty("name").GetString() == "PanelTarget");
Check(listed, "Player list shows the connected player with ID");
var status = await Call(owner, "GET", "api/status");
Check(status.status == 200 && status.json.GetProperty("players").GetInt32() >= 1, "Status shows players, uptime, world");
var cmd = await Call(owner, "POST", "api/console", new { command = "status" });
Check(cmd.status == 200 && cmd.json.GetProperty("message").GetString().Contains("players"), "Console command runs and returns its output");
// Mod-Liste des Keymasters: kleiner Ersatz-Keymaster auf localhost
{
var modBytes = new byte[3000];
Random.Shared.NextBytes(modBytes);
modBytes[0] = 0x4d; modBytes[1] = 0x5a;
string modSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(modBytes)).ToLowerInvariant();
bool serveBroken = false;
int catalogPort = 39000 + Random.Shared.Next(900);
var listener = new System.Net.HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{catalogPort}/");
listener.Start();
_ = Task.Run(async () =>
{
while (listener.IsListening)
{
System.Net.HttpListenerContext c;
try { c = await listener.GetContextAsync(); } catch { break; }
byte[] body;
if (c.Request.Url.AbsolutePath == "/api/mods")
{
body = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new
{
mods = new[] { new { id = "catalogmod", name = "Catalog Mod", description = "From the list", author = "Tester", version = "1.0.0",
sha256 = modSha, size = modBytes.Length, changelog = "", updatedUtc = "2026-10-03", downloads = 3,
url = "http://evil.example/steal.dll" } }
}));
c.Response.ContentType = "application/json";
}
else if (c.Request.Url.AbsolutePath == "/mods/download/catalogmod/1.0.0")
{
body = serveBroken ? new byte[] { 0x4d, 0x5a, 1, 2, 3 } : modBytes;
}
else { c.Response.StatusCode = 404; body = Array.Empty<byte>(); }
await c.Response.OutputStream.WriteAsync(body);
c.Response.Close();
}
});
ModCatalog.BaseUrl = $"http://127.0.0.1:{catalogPort}";
var catalog = await Call(owner, "GET", "api/mods/catalog");
Check(catalog.status == 200 && catalog.json.EnumerateArray().Any(m => m.GetProperty("id").GetString() == "catalogmod" && !m.GetProperty("installed").GetBoolean()),
"Panel lists the MSCO mod list (not yet added)");
serveBroken = true;
var broken = await Call(owner, "POST", "api/mods/catalog/catalogmod/install");
Check(broken.status == 400 && !File.Exists(Path.Combine(dir, "mods", "catalogmod.dll")), "A download with the wrong checksum is not added");
serveBroken = false;
Check((await Call(mod1Placeholder(), "POST", "api/mods/catalog/catalogmod/install")).status is 401 or 403, "Without admin rights nothing can be added");
var installMod = await Call(owner, "POST", "api/mods/catalog/catalogmod/install");
Check(installMod.status == 200 && File.Exists(Path.Combine(dir, "mods", "catalogmod.dll"))
&& File.ReadAllBytes(Path.Combine(dir, "mods", "catalogmod.dll")).SequenceEqual(modBytes),
"Admin adds a mod from the list (downloaded from the keymaster, checksum checked)");
catalog = await Call(owner, "GET", "api/mods/catalog");
Check(catalog.json.EnumerateArray().Any(m => m.GetProperty("id").GetString() == "catalogmod" && m.GetProperty("upToDate").GetBoolean()),
"The list shows the mod as added");
Check((await Call(owner, "POST", "api/mods/catalog/doesnotexist/install")).status == 400, "Unknown mods cannot be added");
await Call(owner, "POST", "api/mods/catalogmod/remove");
listener.Stop();
ModCatalog.BaseUrl = MasterServer.Url;
}
HttpClient mod1Placeholder() => Browser();
var log = await Call(owner, "GET", "api/console?after=0");
Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable");
var add = await Call(owner, "POST", "api/users", new { name = "mod1", password = "modpassword", role = "Moderator" });
Check(add.status == 200, "Owner adds a moderator");
var mod = Browser();
Check((await Call(mod, "POST", "api/login", new { username = "mod1", password = "nope-nope" })).status == 401, "Wrong password is rejected");
Check((await Call(mod, "POST", "api/login", new { username = "mod1", password = "modpassword" })).status == 200, "Moderator logs in");
Check((await Call(mod, "POST", "api/console", new { command = "stop" })).status == 403, "Moderator cannot run console commands");
Check((await Call(mod, "POST", "api/world/reset")).status == 403, "Moderator cannot reset the world");
Check((await Call(mod, "GET", "api/users")).status == 403, "Moderator cannot manage users");
var modPlayers = await Call(mod, "GET", "api/players");
Check(modPlayers.status == 200 && modPlayers.json.EnumerateArray().All(p => p.GetProperty("address").ValueKind == System.Text.Json.JsonValueKind.Null),
"Moderators do not see IP addresses");
int targetId = gamer.Id;
var kick = await Call(mod, "POST", $"api/players/{targetId}/kick", new { reason = "panel test" });
Check(kick.status == 200, "Moderator kicks a player from the panel: " + (kick.json.ValueKind == System.Text.Json.JsonValueKind.Object ? kick.json.ToString() : ""));
var kickMsg = await gamer.WaitFor(MsgType.Reject);
string kickReason = kickMsg == null ? null : ReadString(kickMsg);
Check(kickReason != null && kickReason.Contains("panel test") && kickReason.Contains("mod1"), "The kicked player gets the reason and who kicked: " + kickReason);
var ban = await Call(owner, "POST", "api/bans/add", new { id = "76561198000000001" });
var bans = await Call(owner, "GET", "api/bans");
Check(ban.status == 200 && bans.json.EnumerateArray().Any(b => b.GetString() == "76561198000000001"), "Offline SteamID can be banned");
await Call(owner, "POST", "api/bans/remove", new { id = "76561198000000001" });
var backup = await Call(owner, "POST", "api/world/backup");
Check(backup.status == 200, "Backup from the panel");
Check((await Call(owner, "POST", "api/users/owner/delete")).status == 400, "Owner cannot delete themselves");
// Zu viele Fehlversuche -> gesperrt
var bruteforce = Browser();
int lastStatus = 0;
for (int i = 0; i < 10; i++) lastStatus = (await Call(bruteforce, "POST", "api/login", new { username = "owner", password = "guess" + i })).status;
Check(lastStatus == 429, "Login is rate limited after too many failures");
// Einstellungen
var cfg = await Call(owner, "GET", "api/config");
Check(cfg.status == 200 && cfg.json.GetProperty("licenseKeySet").GetBoolean() && !cfg.json.ToString().Contains("\"test\""),
"Settings are readable, keys are never sent to the browser");
var cfgSave = await Call(owner, "POST", "api/config", new { name = "Renamed Server", maxPlayers = 6 });
Check(cfgSave.status == 200 && config.Name == "Renamed Server" && config.MaxPlayers == 6
&& File.ReadAllText(configPath).Contains("Renamed Server"), "Settings are changed and saved to server.json");
Check((await Call(mod, "POST", "api/config", new { name = "Hacked" })).status == 403, "Moderators cannot change settings");
// Server stoppen - das Panel bleibt da
var online = await TestClient.Join("OnlineWhenStopped", 0, null);
var stop = await Call(owner, "POST", "api/server/stop");
Check(stop.status == 200, "Server can be stopped from the panel");
var stopMsg = await online.WaitFor(MsgType.Reject, 3000);
Check(stopMsg != null && ReadString(stopMsg).Contains("stopped by owner"), "Players get told who stopped the server: " + (stopMsg == null ? "-" : ReadString(stopMsg)));
await WaitForState(HostState.Stopped);
Check(host.State == HostState.Stopped && host.Server == null, "The game server is stopped");
Check((await Call(owner, "GET", "api/session")).status == 200, "The panel is still reachable while the server is stopped");
var offStatus = await Call(owner, "GET", "api/status");
Check(offStatus.status == 200 && offStatus.json.GetProperty("state").GetString() == "stopped", "Status shows the stopped server");
Check((await Call(owner, "GET", "api/players")).status == 200, "Player list works while stopped (empty)");
var whileStopped = await TestClient.Join("WhileStopped", 0, null, expectReject: true);
Check(whileStopped.RejectReason != null && whileStopped.RejectReason.Contains("offline"), "Players are told the server is offline: " + whileStopped.RejectReason);
var offCfg = await Call(owner, "POST", "api/config", new { description = "edited while stopped" });
Check(offCfg.status == 200 && File.ReadAllText(configPath).Contains("edited while stopped"), "Settings can be changed while stopped");
var offBackup = await Call(owner, "POST", "api/world/backup");
Check(offBackup.status == 200 && offBackup.json.GetProperty("message").GetString().Contains("Backup"), "Backups work while stopped");
Check((await Call(owner, "POST", "api/players/1/kick")).status == 409, "Player actions say the server is not running");
// Spielstand hochladen, während der Server aus ist - wird beim nächsten Start benutzt
byte[] offlineZip;
using (var oms = new MemoryStream())
{
using (var oa = new System.IO.Compression.ZipArchive(oms, System.IO.Compression.ZipArchiveMode.Create, true))
using (var w = new StreamWriter(oa.CreateEntry("defaultES2File.txt").Open())) w.Write("OFFLINE SAVE");
offlineZip = oms.ToArray();
}
var offUp = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(offlineZip) };
offUp.Headers.Add("X-MSCO-Panel", "1");
offUp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
var offUpRes = await owner.SendAsync(offUp);
Check(offUpRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "OFFLINE SAVE"
&& File.ReadAllText(Path.Combine(dir, "save_original", "defaultES2File.txt")) == "OFFLINE SAVE",
"A save game ZIP can be uploaded while the server is stopped (also the new reset point)");
Check((await Call(mod, "POST", "api/server/start")).status == 403, "Moderators cannot start the server");
Check((await Call(owner, "POST", "api/server/start")).status == 200, "Server can be started again from the panel");
await WaitForState(HostState.Running);
Check(host.State == HostState.Running, "The server runs again");
var afterStart = await TestClient.Join("AfterStart", 0, null);
Check(afterStart.Welcome != null, "Players can join after start");
Check(afterStart.Files.TryGetValue("defaultES2File.txt", out var startedSave) && System.Text.Encoding.UTF8.GetString(startedSave) == "OFFLINE SAVE",
"After start the server uses the save uploaded while it was stopped");
afterStart.Close();
Check((await Call(mod, "POST", "api/server/stop")).status == 403, "Moderators cannot stop the server");
// Spielstand als ZIP
byte[] zip;
using (var zms = new MemoryStream())
{
using (var za = new System.IO.Compression.ZipArchive(zms, System.IO.Compression.ZipArchiveMode.Create, true))
{
using (var w = new StreamWriter(za.CreateEntry("My Summer Car/defaultES2File.txt").Open())) w.Write("ZIPPED SAVE");
using (var w = new StreamWriter(za.CreateEntry("../../evil.txt").Open())) w.Write("should be ignored");
}
zip = zms.ToArray();
}
var up = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(zip) };
up.Headers.Add("X-MSCO-Panel", "1");
up.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
var upRes = await owner.SendAsync(up);
string upText = await upRes.Content.ReadAsStringAsync();
Check(upRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "ZIPPED SAVE",
"A save game ZIP can be uploaded: " + upText);
Check(upText.Contains("items.txt is missing"), "Uploading a save without items.txt warns (bought parts like the oil filter would be missing)");
Check(!File.Exists(Path.Combine(dir, "..", "evil.txt")) && !File.Exists(Path.Combine(dir, "evil.txt")), "Paths inside the ZIP are never used");
Check(File.ReadAllText(Path.Combine(dir, "save_original", "defaultES2File.txt")) == "ZIPPED SAVE", "The uploaded save is the new reset point");
// RAR (mit WinRAR gepackt, "solid", Unterordner, zusätzliche fremde Datei)
var rarBytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "save.rar"));
var rarUp = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(rarBytes) };
rarUp.Headers.Add("X-MSCO-Panel", "1");
rarUp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
var rarRes = await owner.SendAsync(rarUp);
Check(rarRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "RAR SAVE"
&& File.ReadAllText(Path.Combine(dir, "save", "items.txt")) == "rar items" && !File.Exists(Path.Combine(dir, "save", "readme.txt")),
"A RAR archive can be uploaded (only the save files are taken): " + await rarRes.Content.ReadAsStringAsync());
Check(!(await rarRes.Content.ReadAsStringAsync()).Contains("items.txt is missing"), "No items.txt warning when the save has items.txt");
var junk = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("this is no archive")) };
junk.Headers.Add("X-MSCO-Panel", "1");
var junkRes = await owner.SendAsync(junk);
Check(!junkRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "RAR SAVE",
"A broken upload is rejected and the save stays untouched");
var down = await owner.GetAsync("api/save/download");
byte[] downZip = await down.Content.ReadAsByteArrayAsync();
using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip)))
Check(down.IsSuccessStatusCode && da.GetEntry("defaultES2File.txt") != null, "The current save can be downloaded as ZIP");
// Eigene Tags: bereinigt, "official"/"featured" nur über die MSCO-Admins
var cleanTags = ServerConfig.NormalizeTags(new[] { " Deutsch ", "Role Play!", "OFFICIAL", "für alle", "deutsch", "featured" }, out var reservedTags);
Check(string.Join(",", cleanTags) == "deutsch,role-play,fuer-alle" && string.Join(",", reservedTags) == "official,featured",
"Tags are normalized and official/featured are reserved: " + string.Join(",", cleanTags));
var tagRes = await Call(owner, "POST", "api/config", new { tags = "Vanilla, official, Deutsch" });
Check(tagRes.status == 200 && tagRes.json.GetProperty("message").GetString().Contains("only given by the MSCO admins")
&& string.Join(",", ServerConfig.LoadOrCreate(configPath, out _).Tags) == "vanilla,deutsch",
"Own tags can be set in the panel, official is refused: " + tagRes.json);
var tagCfg = await Call(owner, "GET", "api/config");
Check(tagCfg.json.GetProperty("tags").GetString() == "vanilla, deutsch", "The panel shows the tags");
// Updates: Versionen vergleichen, Status im Panel, Installieren nur mit verfügbarem Update
Check(Updater.ParseCsprojVersion("<Project><PropertyGroup><Version>2.10.1</Version></PropertyGroup></Project>") == "2.10.1"
&& Updater.ParseModVersion("public const string ModVersion = \"2.9\";") == "2.9", "Update versions are read from the source");
Check(Updater.IsNewer("2.10.0", "2.9.0") && !Updater.IsNewer("2.9.0", "2.9.0") && !Updater.IsNewer("2.8.5", "2.9.0") && !Updater.IsNewer("x", "2.9.0"),
"Versions are compared numerically (2.10 > 2.9)");
var upd = await Call(owner, "GET", "api/update");
Check(upd.status == 200 && upd.json.GetProperty("current").GetString() == Updater.CurrentVersion && upd.json.GetProperty("step").GetString() == "idle",
"The panel shows the installed server version: " + upd.json);
Check((await Call(owner, "POST", "api/update/install")).status == 409, "Installing without an available update is refused");
Check(Updater.RunInstalled(Array.Empty<string>(), dir) == null, "Without an installed update the built-in version runs");
// Lizenz: Master fällt aus -> der laufende Server läuft bis zum nächsten Neustart weiter
LicenseCache.Save(dir, "test", "Tester", "Test", DateTime.UtcNow.AddHours(-5));
var cachedLicense = LicenseCache.Load(dir, "test");
Check(cachedLicense != null && cachedLicense.Owner == "Tester" && LicenseCache.Load(dir, "other-key") == null
&& !File.ReadAllText(Path.Combine(dir, "license.json")).Contains("\"test\""),
"The last license check is remembered (only a hash of the key)");
Srv().LicenseConfirmedUtc = DateTime.UtcNow.AddHours(-10); // innerhalb der 72 h: läuft weiter
await Task.Delay(500);
Check(host.State == HostState.Running, "Server keeps running while the license is unconfirmed");
Srv().LicenseConfirmedUtc = DateTime.UtcNow.AddHours(-100); // länger als die 72 h für einen Start
await Task.Delay(1500);
Check(host.State == HostState.Running, "A running server is never stopped by the license check - it runs until the next restart");
// Panel neu starten: eigener Port, danach wieder zurück auf den Spiel-Port - angemeldet bleiben
Check((await Call(owner, "POST", "api/config", new { panelPort = 17791 })).status == 200, "Panel port can be changed");
var pr = await Call(owner, "POST", "api/server/restart-panel");
Check(pr.status == 200 && pr.json.GetProperty("url").GetString().Contains(":17791/"), "Restart panel returns the new address: " + pr.json);
int code = 0;
for (int i = 0; i < 30 && code != 200; i++)
{
await Task.Delay(300);
try { code = (int)(await owner.GetAsync("http://127.0.0.1:17791/api/status")).StatusCode; } catch { }
}
Check(code == 200, "Panel answers on the new port and the login is kept");
var back = new HttpRequestMessage(HttpMethod.Post, "http://127.0.0.1:17791/api/config")
{ Content = new StringContent("{\"panelPort\":0}", System.Text.Encoding.UTF8, "application/json") };
back.Headers.Add("X-MSCO-Panel", "1");
await owner.SendAsync(back);
var back2 = new HttpRequestMessage(HttpMethod.Post, "http://127.0.0.1:17791/api/server/restart-panel");
back2.Headers.Add("X-MSCO-Panel", "1");
await owner.SendAsync(back2);
code = 0;
for (int i = 0; i < 30 && code != 200; i++)
{
await Task.Delay(300);
try { code = (await Call(owner, "GET", "api/status")).status; } catch { }
}
Check(code == 200 && host.Panel.SharesGamePort, "Panel is back on the game port");
// Neues Spiel statt hochgeladenem Spielstand
var ng = await Call(owner, "POST", "api/world/newgame");
Check(ng.status == 200 && File.Exists(Path.Combine(dir, "save", SaveStore.NewGameMarker)) && !SaveStore.HasSave(dir),
"New game: the save is backed up and replaced by a new-game marker: " + ng.json);
await WaitForState(HostState.Running);
for (int i = 0; i < 40 && (Srv() == null || !Srv().NewGame); i++) await Task.Delay(100);
Check(Srv() != null && Srv().NewGame && SaveStore.ResetPointIsNewGame(dir), "The server runs a new game (also as reset point)");
var fresh = await TestClient.Join("Fresh", 0, null);
var start = fresh.StartGamePayload;
bool startsNew = false;
if (start != null && start.Length >= 2) { var sr = new BinaryReader(new MemoryStream(start)); sr.ReadBoolean(); startsNew = sr.ReadBoolean(); }
Check(startsNew, "Joining players are told to start a new game");
fresh.Close();
await Call(owner, "POST", "api/logout");
Check((await Call(owner, "GET", "api/status")).status == 401, "Logout ends the session");
}
Console.WriteLine($"\n{passed} passed, {failed} failed");
host.RequestExit();
hostThread.Join(5000);
try { Directory.Delete(dir, true); } catch { }
return failed == 0 ? 0 : 1;
static byte[] PlayerPos(float x, float y, float z) => Wire.Build(w => { w.Write(x); w.Write(y); w.Write(z); w.Write(0f); });
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<int> ObjectIds = new();
public float[] Spawn;
public int Spawns = -1;
public int Appeared = -1;
public List<int> Destroyed = new();
public Dictionary<string, float> Needs = new();
public int Vars = -1;
public int JobVars = -1;
public Dictionary<int, string> Controls = new();
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()) s.Spawn = new[] { 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.Spawns = r.ReadInt32();
for (int i = 0; i < s.Spawns; i++) r.ReadBytes(r.ReadInt32());
s.Appeared = r.ReadInt32();
for (int i = 0; i < s.Appeared; i++) r.ReadBytes(r.ReadInt32());
int destroyed = r.ReadInt32();
for (int i = 0; i < destroyed; i++) s.Destroyed.Add(r.ReadInt32());
if (r.ReadBoolean())
{
int needs = r.ReadInt32();
for (int i = 0; i < needs; i++) s.Needs[r.ReadString()] = r.ReadSingle();
}
s.JobVars = r.ReadInt32();
for (int i = 0; i < s.JobVars; i++) { r.ReadInt32(); byte t = r.ReadByte(); r.ReadBytes(t == 3 ? 1 : 4); }
int controls = r.ReadInt32();
for (int i = 0; i < controls; i++) { int key = r.ReadInt32(); s.Controls[key] = r.ReadString(); }
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 byte[] StartGamePayload;
public string RejectReason;
public Dictionary<string, byte[]> Files = new();
public static async Task<TestClient> 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(GameServer.ModVersion);
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, 20000); // kann warten, bis ein anderer Spieler den aktuellen Spielstand hochlädt
byte[] sf;
while ((sf = await c.WaitFor(MsgType.SaveFile, 1500)) != null)
{
c.SaveFiles++;
var fr = new BinaryReader(new MemoryStream(sf));
string fname = fr.ReadString();
c.Files[fname] = fr.ReadBytes(fr.ReadInt32());
}
c.StartGamePayload = await c.WaitFor(MsgType.StartGame, 1500);
c.GotStartGame = c.StartGamePayload != 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<byte[]> 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 { } }
}