Panel independent of the game server: ServerHost owns port + panel and starts/stops the game server; panel works while stopped/crashed/unconfigured (settings, save ZIP upload, backups, bans offline); crash auto-restart; players told 'offline'; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 19:02:08 +02:00
co-authored by Claude Opus 5.5
parent f8aad82d18
commit c74c48dfc8
11 changed files with 1012 additions and 417 deletions
+65 -29
View File
@@ -15,14 +15,17 @@ string configPath = Path.Combine(dir, "server.json");
var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } };
config.Save(configPath);
var server = new GameServer(config, dir, configPath, "Tester", offline: true);
server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; }
// Web-Panel auf demselben Port wie das Spiel (Standard)
server.Panel = AdminPanel.Start(server, config, dir);
var serverThread = new Thread(server.Run) { IsBackground = true };
serverThread.Start();
Thread.Sleep(500);
// 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); }
@@ -151,7 +154,7 @@ try
var hungry = await TestClient.Join("Hungry", 0, null);
hungry.Send(MsgType.Ready, Array.Empty<byte>());
await hungry.WaitFor(MsgType.WorldSnapshot);
server.EnqueueCommand("save"); // (Zeitfenster TrackPositionFrom abwarten)
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);
@@ -197,11 +200,11 @@ try
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");
server.EnqueueCommand("ban " + noAdmin.Id + " test");
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);
server.EnqueueCommand("unban name:noadmin");
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");
@@ -214,7 +217,7 @@ try
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);
server.EnqueueCommand("kick " + spammer.Id + " testing");
Srv().EnqueueCommand("kick " + spammer.Id + " testing");
var kickMsg = await spammer.WaitFor(MsgType.Reject, 3000);
spamming = false;
await spamTask;
@@ -225,7 +228,7 @@ try
Console.WriteLine("== reset-world makes everyone reconnect");
var r1 = await TestClient.Join("Resetter", 0, null);
server.EnqueueCommand("reset-world");
Srv().EnqueueCommand("reset-world");
var rec = await r1.WaitFor(MsgType.Reconnect);
Check(rec != null && new BinaryReader(new MemoryStream(rec)).ReadString().Contains("reset"), "Players get the 'world is being reset' reconnect message");
await Task.Delay(300);
@@ -269,19 +272,19 @@ try
string backupsDir = Path.Combine(dir, "save_backups");
int before = Directory.GetDirectories(backupsDir).Length;
await Task.Delay(1100); // Backups heißen nach der Sekunde
server.EnqueueCommand("backup");
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");
server.EnqueueCommand("restore-save 1");
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);
server.EnqueueCommand("reset-world");
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");
@@ -303,7 +306,7 @@ catch (Exception e)
async Task PanelTests()
{
Console.WriteLine("== Web panel on the game port");
Check(server.Panel != null && server.Panel.SharesGamePort, "Panel started and shares 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()
{
@@ -334,10 +337,10 @@ async Task PanelTests()
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 = server.Panel.SetupCode, username = "owner", password = "supersecret1" }, csrf: false)).status == 403,
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 = server.Panel.SetupCode, username = "owner", password = "supersecret1" });
Check(setup.status == 200 && server.Panel.SetupCode == null, "Setup with the console code creates the owner and logs in");
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");
@@ -399,17 +402,50 @@ async Task PanelTests()
&& 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");
// Spiel stoppen / starten
var stop = await Call(owner, "POST", "api/server/stop-game");
Check(stop.status == 200, "Game server can be stopped from the panel");
// 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("stopped"), "Nobody can join while stopped: " + whileStopped.RejectReason);
Check((await Call(owner, "GET", "api/status")).json.GetProperty("gameStopped").GetBoolean(), "Status shows the stopped game server");
Check((await Call(owner, "POST", "api/server/start-game")).status == 200, "Game server can be started again");
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-game")).status == 403, "Moderators cannot stop the server");
Check((await Call(mod, "POST", "api/server/stop")).status == 403, "Moderators cannot stop the server");
// Spielstand als ZIP
byte[] zip;
@@ -440,8 +476,8 @@ async Task PanelTests()
}
Console.WriteLine($"\n{passed} passed, {failed} failed");
server.EnqueueCommand("stop");
Thread.Sleep(300);
host.RequestExit();
hostThread.Join(5000);
try { Directory.Delete(dir, true); } catch { }
return failed == 0 ? 0 : 1;