World & Backups -> Start new game (console: new-game, also when stopped): the save is backed up and replaced by a new-game marker (also as reset point). Joining players are told to start a new game; the server pulls the first player's save and from then on runs normally. Reset world returns to a new game when the reset point is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
14 KiB
C#
298 lines
14 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")) || File.Exists(Path.Combine(OriginalPath(dataDir), NewGameMarker));
|
|
|
|
/// <summary>
|
|
/// "Neues Spiel": statt Spielstand-Dateien liegt diese Markierung in data/save. Der erste Spieler startet im Spiel ein
|
|
/// neues Spiel, der Server holt sich danach dessen Spielstand. Liegt sie im Reset-Punkt, geht "Reset world" zurück
|
|
/// auf ein neues Spiel.
|
|
/// </summary>
|
|
public const string NewGameMarker = "NEWGAME.txt";
|
|
public static bool IsNewGame(string dataDir) => !HasSave(dataDir) && File.Exists(Path.Combine(SavePath(dataDir), NewGameMarker));
|
|
public static bool ResetPointIsNewGame(string dataDir) =>
|
|
!File.Exists(Path.Combine(OriginalPath(dataDir), "defaultES2File.txt")) && File.Exists(Path.Combine(OriginalPath(dataDir), NewGameMarker));
|
|
|
|
/// <summary>Aktuellen Spielstand sichern und durch "neues Spiel" ersetzen (auch als Reset-Punkt).</summary>
|
|
public static void MakeNewGame(string dataDir, bool alsoResetPoint)
|
|
{
|
|
Backup(dataDir);
|
|
string save = SavePath(dataDir);
|
|
Directory.CreateDirectory(save);
|
|
foreach (string name in SaveFiles)
|
|
{
|
|
string file = Path.Combine(save, name);
|
|
if (File.Exists(file)) File.Delete(file);
|
|
}
|
|
File.WriteAllText(Path.Combine(save, NewGameMarker), "MSCO: new game - the first player creates a fresh world");
|
|
if (!alsoResetPoint) return;
|
|
string original = OriginalPath(dataDir);
|
|
if (Directory.Exists(original)) Directory.Delete(original, true);
|
|
Directory.CreateDirectory(original);
|
|
File.WriteAllText(Path.Combine(original, NewGameMarker), "MSCO: the reset point is a new game");
|
|
}
|
|
|
|
/// <summary>Echter Spielstand ist da: die Neues-Spiel-Markierung entfernen.</summary>
|
|
public static void ClearNewGameMarker(string dataDir)
|
|
{
|
|
string marker = Path.Combine(SavePath(dataDir), NewGameMarker);
|
|
try { if (File.Exists(marker)) File.Delete(marker); } catch { }
|
|
}
|
|
|
|
/// <summary>Server aus: neues Spiel einrichten.</summary>
|
|
public static string NewGameOffline(string dataDir, string by)
|
|
{
|
|
try
|
|
{
|
|
MakeNewGame(dataDir, alsoResetPoint: true);
|
|
ResetWorldFile(dataDir, full: true);
|
|
}
|
|
catch (Exception e) { return "Could not start a new game: " + e.Message; }
|
|
GameServer.Log($"New game set up by {by} (server stopped) - the previous save game is in the backups");
|
|
return "New game set up - start the server; the first player to join creates the new world";
|
|
}
|
|
|
|
/// <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 (IsNewGame(dataDir))
|
|
{
|
|
// Neues Spiel als Reset-Punkt: "Reset world" startet wieder ein neues Spiel
|
|
string orig = OriginalPath(dataDir);
|
|
if (Directory.Exists(orig)) Directory.Delete(orig, true);
|
|
Directory.CreateDirectory(orig);
|
|
File.WriteAllText(Path.Combine(orig, NewGameMarker), "MSCO: the reset point is a new game");
|
|
return null;
|
|
}
|
|
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);
|
|
ClearNewGameMarker(dataDir);
|
|
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 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.
|
|
/// </summary>
|
|
public static string ExtractArchive(byte[] data, out Dictionary<string, byte[]> found)
|
|
{
|
|
found = new Dictionary<string, byte[]>(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<Stream> 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;
|
|
}
|
|
|
|
/// <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 = 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";
|
|
}
|
|
|
|
/// <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 (ResetPointIsNewGame(dataDir)) MakeNewGame(dataDir, alsoResetPoint: false);
|
|
else 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);
|
|
}
|
|
}
|