Files
MSCO-Dedicated-Server/Tests/Program.cs
T
MrMahoandClaude Opus 5.5 266a462fc3 Server 2.10.2: warn when an uploaded save has no items.txt
Bought parts (oil filter, spark plugs, fan belt, battery ...) and whether they are
installed are stored in items.txt. Without it they are missing on the car - e.g. the
finished Satsuma of a downloaded save loses oil on the server (in singleplayer an old
local items.txt hides the problem). The upload result now says so. 2 new tests (148 pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:41:24 +02:00

831 lines
51 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");
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 { } }
}