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();
}