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 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:43:48 +02:00
co-authored by Claude Opus 5.5
parent 9444ffa109
commit 0f62c17c90
4 changed files with 94 additions and 26 deletions
+73 -25
View File
@@ -357,6 +357,10 @@ public sealed class GameServer
// Gebannte Spieler (für UNBAN) - am Ende angehängt, ältere Mods lesen hier nicht weiter // Gebannte Spieler (für UNBAN) - am Ende angehängt, ältere Mods lesen hier nicht weiter
w.Write(config.BannedSteamIds.Count); w.Write(config.BannedSteamIds.Count);
foreach (string id in config.BannedSteamIds) w.Write(id); 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; return;
@@ -370,6 +374,15 @@ public sealed class GameServer
string result; string result;
if (!c.IsAdmin) result = "You are not an admin on this server"; 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 == 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 (!clients.TryGetValue(targetId, out var target) || target.State != ClientState.Joined) result = "Player not found";
else if (action == 3) else if (action == 3)
{ {
@@ -656,6 +669,60 @@ public sealed class GameServer
if (count > 0) Log($"{count} player(s) will reconnect"); if (count > 0) Log($"{count} player(s) will reconnect");
} }
/// <summary>Weltzustand zurücksetzen (Konsole oder Admin im Spiel): alle verbinden neu und laden den reinen Spielstand.</summary>
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";
}
/// <summary>Aktuellen Spielstand jetzt als Backup sichern.</summary>
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; }
}
/// <summary>Backup wiederherstellen (Nummer aus 'backups' oder Name/Zeitstempel); alle verbinden neu.</summary>
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 <number> (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) string Unban(string id, string by)
{ {
id = (id ?? "").Trim(); id = (id ?? "").Trim();
@@ -712,7 +779,7 @@ public sealed class GameServer
switch (args[0].ToLowerInvariant()) switch (args[0].ToLowerInvariant())
{ {
case "help": case "help":
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | restore-save <n> | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop"); Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
break; break;
case "say": case "say":
if (args.Length < 2) { Log("say <text>"); break; } if (args.Length < 2) { Log("say <text>"); break; }
@@ -790,10 +857,7 @@ public sealed class GameServer
SaveWorld(); SaveWorld();
break; break;
case "reset-world": case "reset-world":
world.Reset(); ResetWorld("Console");
SaveWorld();
Log("World state reset - new players get the plain save game again.");
ReconnectAll("The world is being reset - reconnecting...");
break; break;
case "backups": 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])}"); for (int i = 0; i < list.Count; i++) Log($" [{i + 1}] {Path.GetFileName(list[i])}");
break; break;
} }
case "backup":
Log(BackupNow("Console"));
break;
case "restore-save": case "restore-save":
{ Log(RestoreSave(args.Length > 1 ? args[1].Trim() : "", "Console"));
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 <number> (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); }
break; break;
}
case "reload-save": case "reload-save":
if (LoadSave()) ReconnectAll("The server loaded a new save game - reconnecting..."); if (LoadSave()) ReconnectAll("The server loaded a new save game - reconnecting...");
break; break;
+1 -1
View File
@@ -30,7 +30,7 @@ public enum MsgType : byte
AdminStatus = 23, // Server -> Client: bool, ist Admin AdminStatus = 23, // Server -> Client: bool, ist Admin
AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin] 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 AdminResult = 27, // Server -> Client: string Meldung
Chat = 28, // Chatnachricht (string) Chat = 28, // Chatnachricht (string)
VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert] VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert]
+1
View File
@@ -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 | | `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 | | `reload-save` | Spielstand-Dateien neu einlesen – verbundene Spieler verbinden sich automatisch neu |
| `backups`, `restore-save <nr>` | gesicherte Spielstände anzeigen / einen davon zurückholen (z.B. wenn der aktuelle kaputt ist) – alle verbinden sich neu | | `backups`, `restore-save <nr>` | 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 | | `stop` | speichern und beenden |
Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban, Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban,
+19
View File
@@ -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(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, 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"); "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(); inGame.Close();
newcomer.Close(); newcomer.Close();
} }