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