Files
MSCO-Dedicated-Server/SaveStore.cs
T

206 lines
9.2 KiB
C#

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