using System.IO.Compression; namespace MSCO.Server; /// /// 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). /// 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")); /// Backup-Ordner, neueste zuerst. public static List BackupDirs(string dataDir) { string backups = BackupsPath(dataDir); return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List(); } /// Aktuellen Spielstand nach save_backups/Zeitstempel kopieren, die letzten Stände behalten. 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 { } } /// Aktuellen Spielstand als Reset-Punkt merken. Gibt einen Fehler zurück oder null. 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; } } /// Dateien in den aktiven Spielstand schreiben; bekannte Dateien, die fehlen, werden gelöscht. public static void WriteSave(string dataDir, IReadOnlyDictionary 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); } } /// Dateien eines Ordners (Backup, Reset-Punkt) lesen - nur die bekannten Spielstand-Dateien. public static Dictionary ReadFolder(string folder) { var files = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (string name in SaveFiles) { string file = Path.Combine(folder, name); if (File.Exists(file)) files[name] = File.ReadAllBytes(file); } return files; } /// Backup per Nummer (1 = neuestes) oder Ordnername finden. 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); } /// /// Spielstand aus einem Archiv lesen (ZIP, RAR, 7z, tar ...). Nur die bekannten Spielstand-Dateien werden genommen /// (egal in welchem Ordner im Archiv) - Pfade aus dem Archiv werden nie benutzt. Gibt einen Fehler zurück oder null. /// public static string ExtractArchive(byte[] data, out Dictionary found) { found = new Dictionary(StringComparer.OrdinalIgnoreCase); try { using var archive = SharpCompress.Archives.ArchiveFactory.OpenArchive(new MemoryStream(data), null); long total = 0; var result = found; // Eintrag lesen: nur bekannte Spielstand-Dateien, Größe begrenzt. Gibt einen Fehler zurück oder null. string Take(SharpCompress.Common.IEntry entry, Func open) { if (entry.IsDirectory || entry.Key == null) return null; string file = Path.GetFileName(entry.Key.Replace('\\', '/')); string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase)); if (known == null) return null; if (entry.IsEncrypted) return "The archive is password protected"; using var s = open(); using var ms = new MemoryStream(); var chunk = new byte[81920]; int read; while ((read = s.Read(chunk, 0, chunk.Length)) > 0) { total += read; if (total > MaxZipBytes) return "The save game is too large"; ms.Write(chunk, 0, read); } result[known] = ms.ToArray(); return null; } string error = null; if (archive.IsSolid || archive.Type == SharpCompress.Common.ArchiveType.SevenZip) { // "Solide" RAR/7z: nur der Reihe nach lesbar using var reader = archive.ExtractAllEntries(); while (error == null && reader.MoveToNextEntry()) error = Take(reader.Entry, reader.OpenEntryStream); } else { foreach (var entry in archive.Entries) { error = Take(entry, entry.OpenEntryStream); if (error != null) break; } } if (error != null) return error; } catch (Exception e) when (e is not OutOfMemoryException) { return "This file could not be read - upload a ZIP, RAR or 7z archive (" + e.Message + ")"; } if (!found.ContainsKey("defaultES2File.txt")) return "No defaultES2File.txt in the archive - pack the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"; return null; } /// Aktuellen Spielstand als ZIP. 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) /// Server aus: ZIP als neuen Spielstand übernehmen (Backup vorher, neuer Reset-Punkt, Weltzustand leer). public static string ImportZipOffline(string dataDir, byte[] zip, string by) { string error = ExtractArchive(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"; } /// Server aus: Backup wiederherstellen. 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"; } /// Server aus: Welt komplett auf den Reset-Punkt zurück. 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"; } /// world.json anpassen: full = alles weg, sonst nur was im neuen Spielstand steckt. 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); } }