From 0f62c17c909e746afd34ce02e0a5e40665daef0b Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sun, 27 Sep 2026 16:43:48 +0200 Subject: [PATCH] Admin world actions (reset, backup, restore) over AdminAction; backup list in admin list; 'backup' command; safer restore via staging; tests Co-Authored-By: Claude Opus 5.5 --- GameServer.cs | 98 ++++++++++++++++++++++++++++++++++++------------ Protocol.cs | 2 +- README.md | 1 + Tests/Program.cs | 19 ++++++++++ 4 files changed, 94 insertions(+), 26 deletions(-) diff --git a/GameServer.cs b/GameServer.cs index 354ed3a..7adf782 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -357,6 +357,10 @@ public sealed class GameServer // Gebannte Spieler (für UNBAN) - am Ende angehängt, ältere Mods lesen hier nicht weiter w.Write(config.BannedSteamIds.Count); foreach (string id in config.BannedSteamIds) w.Write(id); + // Spielstand-Backups (neueste zuerst) für den WORLD-Reiter + var backups = SaveBackups(); + w.Write(backups.Count); + foreach (string b in backups) w.Write(Path.GetFileName(b)); })); return; @@ -370,6 +374,15 @@ public sealed class GameServer string result; if (!c.IsAdmin) result = "You are not an admin on this server"; else if (action == 4) result = Unban(reason, c.Name); // "reason" enthält hier die gebannte ID + else if (action == 5 || action == 7) + { + // Welt zurücksetzen / Backup wiederherstellen: alle (auch der Admin) verbinden danach neu + c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write(action == 5 ? "Resetting the world..." : "Restoring " + reason + "..."))); + string outcome = action == 5 ? ResetWorld(c.Name) : RestoreSave(reason, c.Name); + if (!c.Conn.IsClosing && !c.Conn.IsClosed) c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write(outcome))); + return; + } + else if (action == 6) result = BackupNow(c.Name); else if (!clients.TryGetValue(targetId, out var target) || target.State != ClientState.Joined) result = "Player not found"; else if (action == 3) { @@ -656,6 +669,60 @@ public sealed class GameServer if (count > 0) Log($"{count} player(s) will reconnect"); } + /// Weltzustand zurücksetzen (Konsole oder Admin im Spiel): alle verbinden neu und laden den reinen Spielstand. + string ResetWorld(string by) + { + world.Reset(); + SaveWorld(); + Log($"World state reset by {by} - new players get the plain save game again."); + ReconnectAll(by == "Console" ? "The world is being reset - reconnecting..." : $"{by} reset the world - reconnecting..."); + return "World reset"; + } + + /// Aktuellen Spielstand jetzt als Backup sichern. + string BackupNow(string by) + { + if (!Directory.Exists(savePath)) return "No save game on the server yet - nothing to back up"; + try + { + BackupSave(); + Log($"Save game backed up by {by}"); + return "Backup created: " + Path.GetFileName(SaveBackups().FirstOrDefault() ?? ""); + } + catch (Exception e) { return "Backup failed: " + e.Message; } + } + + /// Backup wiederherstellen (Nummer aus 'backups' oder Name/Zeitstempel); alle verbinden neu. + string RestoreSave(string arg, string by) + { + var list = SaveBackups(); + string chosen = int.TryParse(arg, out int n) && n >= 1 && n <= list.Count ? list[n - 1] + : list.FirstOrDefault(d => Path.GetFileName(d) == arg); + if (chosen == null) return "restore-save (see 'backups')"; + string name = Path.GetFileName(chosen); + try + { + // Erst in einen Zwischenordner kopieren: BackupSave() unten könnte alte Backups (auch dieses) löschen + string staging = Path.Combine(Path.GetDirectoryName(savePath)!, "save_restore_tmp"); + if (Directory.Exists(staging)) Directory.Delete(staging, true); + Directory.CreateDirectory(staging); + foreach (string file in Directory.GetFiles(chosen)) File.Copy(file, Path.Combine(staging, Path.GetFileName(file)), true); + + BackupSave(); // aktuellen Stand ebenfalls sichern, falls man zurück will + Directory.CreateDirectory(savePath); + foreach (string file in Directory.GetFiles(staging)) File.Copy(file, Path.Combine(savePath, Path.GetFileName(file)), true); + Directory.Delete(staging, true); + if (!LoadSave()) return "Could not load the restored save game"; + world.ResetForNewSave(); + SaveWorld(); + Log($"Restored save game {name} ({by})"); + ReconnectAll(by == "Console" ? "The server restored an older save game - reconnecting..." + : $"{by} restored the save game from {name} - reconnecting..."); + return "Restored " + name; + } + catch (Exception e) { return "Could not restore: " + e.Message; } + } + string Unban(string id, string by) { id = (id ?? "").Trim(); @@ -712,7 +779,7 @@ public sealed class GameServer switch (args[0].ToLowerInvariant()) { case "help": - Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | restore-save | say | op | deop | admins | save | reset-world | reload-save | stop"); + Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | say | op | deop | admins | save | reset-world | reload-save | stop"); break; case "say": if (args.Length < 2) { Log("say "); break; } @@ -790,10 +857,7 @@ public sealed class GameServer SaveWorld(); break; case "reset-world": - world.Reset(); - SaveWorld(); - Log("World state reset - new players get the plain save game again."); - ReconnectAll("The world is being reset - reconnecting..."); + ResetWorld("Console"); break; case "backups": { @@ -803,28 +867,12 @@ public sealed class GameServer for (int i = 0; i < list.Count; i++) Log($" [{i + 1}] {Path.GetFileName(list[i])}"); break; } + case "backup": + Log(BackupNow("Console")); + break; case "restore-save": - { - var list = SaveBackups(); - string arg = args.Length > 1 ? args[1].Trim() : ""; - string chosen = int.TryParse(arg, out int n) && n >= 1 && n <= list.Count ? list[n - 1] - : list.FirstOrDefault(d => Path.GetFileName(d) == arg); - if (chosen == null) { Log("restore-save (see 'backups')"); break; } - try - { - BackupSave(); // aktuellen Stand ebenfalls sichern, falls man zurück will - foreach (string file in Directory.GetFiles(chosen)) File.Copy(file, Path.Combine(savePath, Path.GetFileName(file)), true); - if (LoadSave()) - { - world.ResetForNewSave(); - SaveWorld(); - Log($"Restored save game {Path.GetFileName(chosen)}"); - ReconnectAll("The server restored an older save game - reconnecting..."); - } - } - catch (Exception e) { Log("Could not restore: " + e.Message); } + Log(RestoreSave(args.Length > 1 ? args[1].Trim() : "", "Console")); break; - } case "reload-save": if (LoadSave()) ReconnectAll("The server loaded a new save game - reconnecting..."); break; diff --git a/Protocol.cs b/Protocol.cs index 8e588a6..8f44b8a 100644 --- a/Protocol.cs +++ b/Protocol.cs @@ -30,7 +30,7 @@ public enum MsgType : byte AdminStatus = 23, // Server -> Client: bool, ist Admin AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin] - AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund + AdminAction = 26, // Client -> Server: byte (1 kick, 2 ban, 3 bring, 4 unban, 5 reset world, 6 backup, 7 restore backup), int Spieler-ID, string Grund/Name AdminResult = 27, // Server -> Client: string Meldung Chat = 28, // Chatnachricht (string) VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert] diff --git a/README.md b/README.md index 5b480f9..76cf6d8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvari | `reset-world` | alle Änderungen seit dem Spielstand verwerfen – verbundene Spieler bekommen eine Meldung und verbinden sich automatisch neu | | `reload-save` | Spielstand-Dateien neu einlesen – verbundene Spieler verbinden sich automatisch neu | | `backups`, `restore-save ` | gesicherte Spielstände anzeigen / einen davon zurückholen (z.B. wenn der aktuelle kaputt ist) – alle verbinden sich neu | +| `backup` | aktuellen Spielstand jetzt sichern | | `stop` | speichern und beenden | Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban, diff --git a/Tests/Program.cs b/Tests/Program.cs index 9be575c..8cc49c8 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -232,6 +232,25 @@ try 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 + server.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"); + 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"); inGame.Close(); newcomer.Close(); }