Files
MrMahoandClaude Opus 5.5 266a462fc3 Server 2.10.2: warn when an uploaded save has no items.txt
Bought parts (oil filter, spark plugs, fan belt, battery ...) and whether they are
installed are stored in items.txt. Without it they are missing on the car - e.g. the
finished Satsuma of a downloaded save loses oil on the server (in singleplayer an old
local items.txt hides the problem). The upload result now says so. 2 new tests (148 pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:41:24 +02:00

308 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>
/// Hinweis, wenn im Spielstand items.txt fehlt: dort stehen alle gekauften Teile und Gegenstände (Ölfilter,
/// Zündkerzen, Keilriemen, Batterie ...) samt "eingebaut". Ohne die Datei fehlen sie am Auto - z.B. läuft Öl aus,
/// weil kein Ölfilter da ist. Im Einzelspieler fällt das nicht auf, wenn dort noch eine alte items.txt liegt.
/// </summary>
public static string MissingItemsWarning(IReadOnlyDictionary<string, byte[]> files) =>
files.ContainsKey("items.txt") ? ""
: " - WARNING: items.txt is missing in this save. Bought parts (oil filter, spark plugs, fan belt, battery ...) are " +
"stored there, so they are missing on the car (e.g. oil runs out without the oil filter). Upload the save with items.txt.";
/// <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" + MissingItemsWarning(files);
}
/// <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);
}
}