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:
+65
-29
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user