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:
+205
@@ -0,0 +1,205 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace MSCO.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Spielstand-Dateien im Datenordner: data/save (aktiv), data/save_original (Reset-Punkt), data/save_backups/Zeitstempel.
|
||||
/// Wird vom laufenden Server benutzt und vom Host/Panel, wenn der Server aus ist (z.B. Spielstand hochladen, bevor er
|
||||
/// überhaupt starten kann).
|
||||
/// </summary>
|
||||
public static class SaveStore
|
||||
{
|
||||
public static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" };
|
||||
public const int KeepBackups = 5;
|
||||
public const long MaxZipBytes = 64L * 1024 * 1024;
|
||||
|
||||
public static string SavePath(string dataDir) => Path.Combine(dataDir, "save");
|
||||
public static string OriginalPath(string dataDir) => Path.Combine(dataDir, "save_original");
|
||||
public static string BackupsPath(string dataDir) => Path.Combine(dataDir, "save_backups");
|
||||
public static string WorldPath(string dataDir) => Path.Combine(dataDir, "world.json");
|
||||
|
||||
public static bool HasSave(string dataDir) => File.Exists(Path.Combine(SavePath(dataDir), "defaultES2File.txt"));
|
||||
public static bool HasResetPoint(string dataDir) => File.Exists(Path.Combine(OriginalPath(dataDir), "defaultES2File.txt"));
|
||||
|
||||
/// <summary>Backup-Ordner, neueste zuerst.</summary>
|
||||
public static List<string> BackupDirs(string dataDir)
|
||||
{
|
||||
string backups = BackupsPath(dataDir);
|
||||
return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>Aktuellen Spielstand nach save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
|
||||
public static void Backup(string dataDir)
|
||||
{
|
||||
string save = SavePath(dataDir);
|
||||
if (!Directory.Exists(save)) return;
|
||||
string backups = BackupsPath(dataDir);
|
||||
string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss"));
|
||||
Directory.CreateDirectory(target);
|
||||
foreach (string file in Directory.GetFiles(save))
|
||||
if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true);
|
||||
foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepBackups))
|
||||
try { Directory.Delete(old, true); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>Aktuellen Spielstand als Reset-Punkt merken. Gibt einen Fehler zurück oder null.</summary>
|
||||
public static string SetResetPoint(string dataDir)
|
||||
{
|
||||
if (!HasSave(dataDir)) return "No save game to use as reset point";
|
||||
try
|
||||
{
|
||||
string original = OriginalPath(dataDir);
|
||||
if (Directory.Exists(original)) Directory.Delete(original, true);
|
||||
Directory.CreateDirectory(original);
|
||||
foreach (string file in Directory.GetFiles(SavePath(dataDir)))
|
||||
if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(original, Path.GetFileName(file)), true);
|
||||
return null;
|
||||
}
|
||||
catch (Exception e) { return "Could not remember the reset point: " + e.Message; }
|
||||
}
|
||||
|
||||
/// <summary>Dateien in den aktiven Spielstand schreiben; bekannte Dateien, die fehlen, werden gelöscht.</summary>
|
||||
public static void WriteSave(string dataDir, IReadOnlyDictionary<string, byte[]> files)
|
||||
{
|
||||
string save = SavePath(dataDir);
|
||||
Directory.CreateDirectory(save);
|
||||
foreach (string name in SaveFiles)
|
||||
{
|
||||
string target = Path.Combine(save, name);
|
||||
if (files.TryGetValue(name, out var data))
|
||||
{
|
||||
File.WriteAllBytes(target + ".tmp", data);
|
||||
File.Move(target + ".tmp", target, true);
|
||||
}
|
||||
else if (File.Exists(target)) File.Delete(target);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dateien eines Ordners (Backup, Reset-Punkt) lesen - nur die bekannten Spielstand-Dateien.</summary>
|
||||
public static Dictionary<string, byte[]> ReadFolder(string folder)
|
||||
{
|
||||
var files = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string name in SaveFiles)
|
||||
{
|
||||
string file = Path.Combine(folder, name);
|
||||
if (File.Exists(file)) files[name] = File.ReadAllBytes(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>Backup per Nummer (1 = neuestes) oder Ordnername finden.</summary>
|
||||
public static string FindBackup(string dataDir, string arg)
|
||||
{
|
||||
var list = BackupDirs(dataDir);
|
||||
arg = (arg ?? "").Trim();
|
||||
return int.TryParse(arg, out int n) && n >= 1 && n <= list.Count ? list[n - 1]
|
||||
: list.FirstOrDefault(d => Path.GetFileName(d) == arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spielstand aus einer ZIP lesen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem Ordner
|
||||
/// der ZIP) - Pfade aus der ZIP werden nie benutzt. Gibt einen Fehler zurück oder null.
|
||||
/// </summary>
|
||||
public static string ExtractZip(byte[] zip, out Dictionary<string, byte[]> found)
|
||||
{
|
||||
found = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
long total = 0;
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
string file = Path.GetFileName(entry.FullName.Replace('\\', '/'));
|
||||
string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase));
|
||||
if (known == null) continue;
|
||||
if (entry.Length > MaxZipBytes || (total += entry.Length) > MaxZipBytes) return "The save game is too large";
|
||||
using var s = entry.Open();
|
||||
using var ms = new MemoryStream();
|
||||
s.CopyTo(ms);
|
||||
found[known] = ms.ToArray();
|
||||
}
|
||||
}
|
||||
catch (InvalidDataException) { return "This is not a valid ZIP file"; }
|
||||
if (!found.ContainsKey("defaultES2File.txt"))
|
||||
return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Aktuellen Spielstand als ZIP.</summary>
|
||||
public static byte[] ExportZip(string dataDir)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
|
||||
{
|
||||
foreach (string name in SaveFiles)
|
||||
{
|
||||
string file = Path.Combine(SavePath(dataDir), name);
|
||||
if (File.Exists(file)) archive.CreateEntryFromFile(file, name);
|
||||
}
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Wenn der Server aus ist (Host/Panel)
|
||||
|
||||
/// <summary>Server aus: ZIP als neuen Spielstand übernehmen (Backup vorher, neuer Reset-Punkt, Weltzustand leer).</summary>
|
||||
public static string ImportZipOffline(string dataDir, byte[] zip, string by)
|
||||
{
|
||||
string error = ExtractZip(zip, out var files);
|
||||
if (error != null) return error;
|
||||
try
|
||||
{
|
||||
Backup(dataDir);
|
||||
WriteSave(dataDir, files);
|
||||
SetResetPoint(dataDir);
|
||||
ResetWorldFile(dataDir, full: true);
|
||||
}
|
||||
catch (Exception e) { return "Could not write the save game: " + e.Message; }
|
||||
GameServer.Log($"New save game uploaded by {by} ({files.Count} files, server stopped) - it is also the new reset point");
|
||||
return $"Save game uploaded ({files.Count} files) - start the server to use it";
|
||||
}
|
||||
|
||||
/// <summary>Server aus: Backup wiederherstellen.</summary>
|
||||
public static string RestoreOffline(string dataDir, string arg, string by)
|
||||
{
|
||||
string chosen = FindBackup(dataDir, arg);
|
||||
if (chosen == null) return "Backup not found";
|
||||
try
|
||||
{
|
||||
var files = ReadFolder(chosen); // erst lesen - Backup() unten könnte alte Backups löschen
|
||||
Backup(dataDir);
|
||||
WriteSave(dataDir, files);
|
||||
ResetWorldFile(dataDir, full: false);
|
||||
}
|
||||
catch (Exception e) { return "Could not restore: " + e.Message; }
|
||||
GameServer.Log($"Restored save game {Path.GetFileName(chosen)} ({by}, server stopped)");
|
||||
return "Restored " + Path.GetFileName(chosen) + " - start the server to use it";
|
||||
}
|
||||
|
||||
/// <summary>Server aus: Welt komplett auf den Reset-Punkt zurück.</summary>
|
||||
public static string ResetOffline(string dataDir, string by)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (HasResetPoint(dataDir))
|
||||
{
|
||||
var files = ReadFolder(OriginalPath(dataDir));
|
||||
Backup(dataDir);
|
||||
WriteSave(dataDir, files);
|
||||
}
|
||||
ResetWorldFile(dataDir, full: true);
|
||||
}
|
||||
catch (Exception e) { return "Could not reset: " + e.Message; }
|
||||
GameServer.Log($"World reset by {by} (server stopped) - back to the reset point");
|
||||
return "World reset to the reset point";
|
||||
}
|
||||
|
||||
/// <summary>world.json anpassen: full = alles weg, sonst nur was im neuen Spielstand steckt.</summary>
|
||||
static void ResetWorldFile(string dataDir, bool full)
|
||||
{
|
||||
string path = WorldPath(dataDir);
|
||||
var world = WorldState.Load(path);
|
||||
if (full) world.Reset(); else world.ResetForNewSave();
|
||||
world.Save(path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user