Panel independent of the game server: ServerHost owns port + panel and starts/stops the game server; panel works while stopped/crashed/unconfigured (settings, save ZIP upload, backups, bans offline); crash auto-restart; players told 'offline'; tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+68
-251
@@ -15,7 +15,8 @@ public sealed class GameServer
|
||||
public const string ModVersion = "2.8"; // muss mit MSCO.ModVersion übereinstimmen
|
||||
public const int ServerId = 0; // Absender-ID des Servers; Spieler bekommen 1, 2, ...
|
||||
|
||||
static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" };
|
||||
static string[] SaveFiles => SaveStore.SaveFiles;
|
||||
string DataDir => Path.GetDirectoryName(savePath)!;
|
||||
const double HeartbeatSeconds = 30;
|
||||
|
||||
enum ClientState { New, Verifying, Joined }
|
||||
@@ -55,7 +56,16 @@ public sealed class GameServer
|
||||
readonly ConcurrentQueue<Action> actions = new(); // vom Web-Panel: laufen in der Server-Schleife
|
||||
readonly Stopwatch clock = Stopwatch.StartNew();
|
||||
|
||||
TcpListener listener;
|
||||
// Neue Verbindungen reicht der Host herein (er besitzt den Port und erkennt Browser fürs Panel)
|
||||
readonly ConcurrentQueue<TcpClient> incoming = new();
|
||||
readonly AutoResetEvent incomingSignal = new(false);
|
||||
|
||||
/// <summary>Vom Host: eine neue Spiel-Verbindung.</summary>
|
||||
public void AcceptExternal(TcpClient tcp)
|
||||
{
|
||||
incoming.Enqueue(tcp);
|
||||
incomingSignal.Set();
|
||||
}
|
||||
List<KeyValuePair<string, byte[]>> saveFiles = new();
|
||||
int nextId = 1;
|
||||
byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand
|
||||
@@ -80,7 +90,6 @@ public sealed class GameServer
|
||||
double nextSaveSync;
|
||||
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
|
||||
const long MaxUploadBytes = 32L * 1024 * 1024;
|
||||
const int KeepSaveBackups = 5;
|
||||
/// <summary>So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten.</summary>
|
||||
public double SaveUploadTimeoutSeconds { get; set; } = 15;
|
||||
const int MaxDestroyPerMinute = 20;
|
||||
@@ -141,9 +150,7 @@ public sealed class GameServer
|
||||
public void Run()
|
||||
{
|
||||
EnsureResetPoint();
|
||||
listener = new TcpListener(IPAddress.Any, config.Port);
|
||||
listener.Start();
|
||||
Log($"Listening on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)");
|
||||
Log($"Game server running on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)");
|
||||
if (!config.RequireSteam) Log("Non-Steam server: players without Steam can join (RequireSteam = false)");
|
||||
else Log(steam.Enabled ? "Steam required - tickets are verified with the Steam Web API" : "Steam required (no Web API key set, tickets are not verified online)");
|
||||
Log($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions");
|
||||
@@ -166,9 +173,18 @@ public sealed class GameServer
|
||||
|
||||
foreach (var c in clients.Values)
|
||||
{
|
||||
// Meist ein Neustart (Update, docker compose up): die Spieler verbinden sich automatisch neu, sobald der
|
||||
// Server wieder da ist. Bleibt er aus, bekommen sie nach ein paar Versuchen einen Hinweis im Hauptmenü.
|
||||
if (c.State == ClientState.Joined) c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write("The server is restarting - reconnecting...")));
|
||||
if (c.State != ClientState.Joined) { c.Conn.CloseAfterSend("Server shutting down"); continue; }
|
||||
if (stoppedBy != null && !RestartRequested)
|
||||
{
|
||||
// Gezielt gestoppt (Panel/Konsole): Grund zeigen, nicht endlos neu verbinden
|
||||
c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write($"The server was stopped by {stoppedBy}")));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neustart / Update (docker compose up): die Spieler verbinden sich automatisch neu, sobald der Server
|
||||
// wieder da ist. Bleibt er aus, bekommen sie nach ein paar Versuchen einen Hinweis im Hauptmenü.
|
||||
c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write("The server is restarting - reconnecting...")));
|
||||
}
|
||||
c.Conn.CloseAfterSend("Server shutting down");
|
||||
}
|
||||
// Kurz warten, bis die Spieler die Meldung gelesen haben (sauber trennen statt hart abbrechen)
|
||||
@@ -179,7 +195,6 @@ public sealed class GameServer
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
foreach (var c in clients.Values) c.Conn.Close("Server shutting down");
|
||||
listener.Stop();
|
||||
SaveWorld();
|
||||
if (!offline)
|
||||
{
|
||||
@@ -189,19 +204,13 @@ public sealed class GameServer
|
||||
Log("Server stopped.");
|
||||
}
|
||||
|
||||
/// <summary>Web-Panel (gesetzt vom Programm). Teilt es sich den Spiel-Port, werden Browser hier erkannt und weitergereicht.</summary>
|
||||
/// <summary>Web-Panel (gehört dem Host, läuft auch, wenn dieser Server aus ist).</summary>
|
||||
public AdminPanel Panel { get; set; }
|
||||
readonly ConcurrentQueue<TcpClient> sniffedGameClients = new();
|
||||
public ServerHost Host { get; set; }
|
||||
|
||||
void AcceptClients()
|
||||
{
|
||||
while (listener.Pending())
|
||||
{
|
||||
var tcp = listener.AcceptTcpClient();
|
||||
if (Panel != null && Panel.SharesGamePort) _ = SniffAsync(tcp);
|
||||
else AddClient(tcp);
|
||||
}
|
||||
while (sniffedGameClients.TryDequeue(out var tcp)) AddClient(tcp);
|
||||
while (incoming.TryDequeue(out var tcp)) AddClient(tcp);
|
||||
}
|
||||
|
||||
void AddClient(TcpClient tcp)
|
||||
@@ -210,35 +219,6 @@ public sealed class GameServer
|
||||
clients[c.Id] = c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gleicher Port für Spiel und Web-Panel: in die ersten Bytes schauen (ohne sie zu verbrauchen). Ein Browser
|
||||
/// schickt "GET /", "POST" ... - das Spiel beginnt mit einer kleinen Längenangabe (Bytes 2-4 sind 0).
|
||||
/// Beide Seiten reden zuerst, der Server muss also nichts senden, bevor er es weiß.
|
||||
/// </summary>
|
||||
async Task SniffAsync(TcpClient tcp)
|
||||
{
|
||||
var buffer = new byte[4];
|
||||
int n;
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (true)
|
||||
{
|
||||
n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token);
|
||||
if (n == 0) { tcp.Close(); return; }
|
||||
if (n >= 4 || buffer[0] < 'A' || buffer[0] > 'Z') break; // kein Buchstabe = sicher das Spiel
|
||||
await Task.Delay(15, timeout.Token);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { tcp.Close(); } catch { }
|
||||
return;
|
||||
}
|
||||
if (AdminPanel.LooksLikeHttp(buffer, n)) await Panel.RelayAsync(tcp);
|
||||
else sniffedGameClients.Enqueue(tcp);
|
||||
}
|
||||
|
||||
void PollClients()
|
||||
{
|
||||
List<Client> closed = null;
|
||||
@@ -285,7 +265,7 @@ public sealed class GameServer
|
||||
}
|
||||
|
||||
// Regelmäßig den aktuellen Spielstand vom Spiel holen
|
||||
if (config.SaveSyncMinutes > 0 && !GameStopped && now >= nextSaveSync)
|
||||
if (config.SaveSyncMinutes > 0 && now >= nextSaveSync)
|
||||
{
|
||||
nextSaveSync = now + config.SaveSyncMinutes * 60.0;
|
||||
RequestSave("regular save");
|
||||
@@ -310,7 +290,7 @@ public sealed class GameServer
|
||||
else if (result.Status == 0) Log("Master server not reachable - server is not listed right now.");
|
||||
else ReportReachability(result);
|
||||
}
|
||||
if (!offline && !GameStopped && now >= nextHeartbeat && heartbeat == null)
|
||||
if (!offline && now >= nextHeartbeat && heartbeat == null)
|
||||
{
|
||||
nextHeartbeat = now + HeartbeatSeconds;
|
||||
heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
|
||||
@@ -523,7 +503,6 @@ public sealed class GameServer
|
||||
// Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken
|
||||
if (version != ModVersion) Log($"{c.Name} uses MSCO {version} (server {ModVersion}) - syncing may not work correctly");
|
||||
if (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; }
|
||||
if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; }
|
||||
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
||||
|
||||
c.State = ClientState.Verifying;
|
||||
@@ -534,7 +513,6 @@ public sealed class GameServer
|
||||
{
|
||||
string error = c.Verification.Result;
|
||||
if (error != null) { Reject(c, error); return; }
|
||||
if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; }
|
||||
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
||||
|
||||
// Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige
|
||||
@@ -694,23 +672,10 @@ public sealed class GameServer
|
||||
}
|
||||
|
||||
/// <summary>Vorhandene Spielstand-Backups, neueste zuerst.</summary>
|
||||
List<string> SaveBackups()
|
||||
{
|
||||
string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups");
|
||||
return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List<string>();
|
||||
}
|
||||
List<string> SaveBackups() => SaveStore.BackupDirs(DataDir);
|
||||
|
||||
/// <summary>Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
|
||||
void BackupSave()
|
||||
{
|
||||
if (!Directory.Exists(savePath)) return;
|
||||
string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups");
|
||||
string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss"));
|
||||
Directory.CreateDirectory(target);
|
||||
foreach (string file in Directory.GetFiles(savePath)) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true);
|
||||
foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepSaveBackups))
|
||||
try { Directory.Delete(old, true); } catch { }
|
||||
}
|
||||
void BackupSave() => SaveStore.Backup(DataDir);
|
||||
|
||||
/// <summary>
|
||||
/// Alle Spieler neu verbinden lassen (nach reset-world / reload-save): sie sehen die Meldung, gehen kurz ins Menü
|
||||
@@ -743,16 +708,9 @@ public sealed class GameServer
|
||||
/// <summary>Aktuellen Spielstand als Reset-Punkt merken.</summary>
|
||||
bool SetResetPoint()
|
||||
{
|
||||
if (!File.Exists(Path.Combine(savePath, "defaultES2File.txt"))) return false;
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(originalPath)) Directory.Delete(originalPath, true);
|
||||
Directory.CreateDirectory(originalPath);
|
||||
foreach (string file in Directory.GetFiles(savePath))
|
||||
if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(originalPath, Path.GetFileName(file)), true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e) { Log("Could not remember the reset point: " + e.Message); return false; }
|
||||
string error = SaveStore.SetResetPoint(DataDir);
|
||||
if (error != null && SaveStore.HasSave(DataDir)) Log(error);
|
||||
return error == null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -870,11 +828,14 @@ public sealed class GameServer
|
||||
void WaitForTraffic()
|
||||
{
|
||||
waitList.Clear();
|
||||
waitList.Add(listener.Server);
|
||||
foreach (var c in clients.Values)
|
||||
if (!c.Conn.IsClosed) waitList.Add(c.Conn.Socket);
|
||||
int micros = clients.Count == 0 ? 50_000 : 5_000; // leerer Server: seltener aufwachen
|
||||
try { Socket.Select(waitList, null, null, micros); }
|
||||
if (waitList.Count == 0)
|
||||
{
|
||||
incomingSignal.WaitOne(50); // leerer Server: warten, bis jemand verbindet (oder Timer fällig)
|
||||
return;
|
||||
}
|
||||
try { Socket.Select(waitList, null, null, 5_000); }
|
||||
catch { Thread.Sleep(2); } // z.B. Socket wurde gerade geschlossen
|
||||
}
|
||||
|
||||
@@ -899,7 +860,7 @@ public sealed class GameServer
|
||||
switch (args[0].ToLowerInvariant())
|
||||
{
|
||||
case "help":
|
||||
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | panel | stop-game | start-game | restart | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
|
||||
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | panel | restart | stop | exit | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
|
||||
break;
|
||||
case "say":
|
||||
if (args.Length < 2) { Log("say <text>"); break; }
|
||||
@@ -1009,21 +970,20 @@ public sealed class GameServer
|
||||
Log(SetResetPoint() ? "The current save game is now the reset point" : "No save game to use as reset point");
|
||||
break;
|
||||
case "stop":
|
||||
stoppedBy = "Console";
|
||||
running = false;
|
||||
Log(Panel != null ? "Stopping the game server - the web panel keeps running ('start' to start again, 'exit' to quit)" : "Stopping ...");
|
||||
break;
|
||||
case "quit":
|
||||
case "exit":
|
||||
running = false;
|
||||
RequestExit();
|
||||
break;
|
||||
case "restart":
|
||||
Log("Restarting (settings, license and save game are loaded again) ...");
|
||||
RestartRequested = true;
|
||||
running = false;
|
||||
break;
|
||||
case "stop-game":
|
||||
Log(StopGame("Console"));
|
||||
break;
|
||||
case "start-game":
|
||||
Log(StartGame("Console"));
|
||||
break;
|
||||
|
||||
default:
|
||||
Log("Unknown command, type 'help'");
|
||||
break;
|
||||
@@ -1106,7 +1066,7 @@ public sealed class GameServer
|
||||
version = ModVersion,
|
||||
players = JoinedCount,
|
||||
maxPlayers = config.MaxPlayers,
|
||||
gameStopped = GameStopped,
|
||||
state = "running",
|
||||
uptimeSeconds = (long)clock.Elapsed.TotalSeconds,
|
||||
port = config.Port,
|
||||
isPublic = config.Public,
|
||||
@@ -1137,153 +1097,51 @@ public sealed class GameServer
|
||||
|
||||
public object PanelBans() => config.BannedSteamIds.ToList();
|
||||
|
||||
// ---------------------------------------------------------------- Start / Stopp / Neustart
|
||||
// ---------------------------------------------------------------- Stopp / Neustart (der Host startet neu oder wartet)
|
||||
|
||||
/// <summary>Spiel offline (Panel läuft weiter): Spieler raus, nicht mehr gelistet, niemand kann beitreten.</summary>
|
||||
public bool GameStopped { get; private set; }
|
||||
/// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary>
|
||||
public bool RestartRequested { get; private set; }
|
||||
/// <summary>Ganzes Programm beenden (Konsole "exit", docker stop) - sonst bleibt das Panel an.</summary>
|
||||
public bool ExitRequested { get; private set; }
|
||||
string stoppedBy; // gesetzt = gezielt gestoppt (Spieler bekommen den Grund statt "verbinde neu")
|
||||
|
||||
public string StopGame(string by)
|
||||
public void RequestExit()
|
||||
{
|
||||
if (GameStopped) return "The game server is already stopped";
|
||||
foreach (var c in clients.Values.Where(x => x.State == ClientState.Joined).ToList())
|
||||
Reject(c, $"The server was stopped by {by}");
|
||||
GameStopped = true;
|
||||
saveUpload = null;
|
||||
waitingForSave.Clear();
|
||||
SaveWorld();
|
||||
Log($"Game server stopped by {by} - players cannot join until it is started again (the panel keeps running)");
|
||||
return "Game server stopped";
|
||||
}
|
||||
|
||||
public string StartGame(string by)
|
||||
{
|
||||
if (!GameStopped) return "The game server is already running";
|
||||
if (!LoadSave()) return "Could not load the save game - see the console";
|
||||
GameStopped = false;
|
||||
HeartbeatSoon();
|
||||
Log($"Game server started by {by}");
|
||||
return "Game server started";
|
||||
ExitRequested = true;
|
||||
running = false;
|
||||
}
|
||||
|
||||
public string PanelServerAction(string action, string by)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "stop-game": return StopGame(by);
|
||||
case "start-game": return StartGame(by);
|
||||
case "stop":
|
||||
Log($"Server stopped by {by} - the panel keeps running");
|
||||
stoppedBy = by;
|
||||
running = false;
|
||||
return "Stopping the server";
|
||||
case "restart":
|
||||
Log($"Restart requested by {by} ...");
|
||||
RestartRequested = true;
|
||||
running = false;
|
||||
return "Restarting - the panel is back in a few seconds";
|
||||
case "shutdown":
|
||||
Log($"Shutdown requested by {by} - the server and the panel stop");
|
||||
running = false;
|
||||
return "Shutting down";
|
||||
default: return "Unknown action";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Einstellungen (server.json) im Panel
|
||||
|
||||
public object PanelConfig() => new
|
||||
{
|
||||
name = config.Name,
|
||||
description = config.Description,
|
||||
maxPlayers = config.MaxPlayers,
|
||||
isPublic = config.Public,
|
||||
requireSteam = config.RequireSteam,
|
||||
publicAddress = config.PublicAddress,
|
||||
saveSyncMinutes = config.SaveSyncMinutes,
|
||||
autosaveSeconds = config.AutosaveSeconds,
|
||||
port = config.Port,
|
||||
panelEnabled = config.PanelEnabled,
|
||||
panelPort = config.PanelPort,
|
||||
panelBindAddress = config.PanelBindAddress,
|
||||
panelTrustProxy = config.PanelTrustProxy,
|
||||
panelBasePath = config.PanelBasePath,
|
||||
licenseKeySet = !string.IsNullOrWhiteSpace(config.LicenseKey),
|
||||
steamWebApiKeySet = !string.IsNullOrWhiteSpace(config.SteamWebApiKey),
|
||||
environmentOverrides = config.EnvironmentOverrides
|
||||
};
|
||||
public object PanelConfig() => ConfigEditor.Build(config);
|
||||
|
||||
/// <summary>Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert.</summary>
|
||||
public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by)
|
||||
{
|
||||
if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings";
|
||||
var changed = new List<string>();
|
||||
bool restartNeeded = false;
|
||||
|
||||
string Text(string name, string current, int max)
|
||||
{
|
||||
if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current;
|
||||
string s = v.GetString().Trim();
|
||||
if (s.Length > max) s = s[..max];
|
||||
if (s != current) changed.Add(name);
|
||||
return s;
|
||||
}
|
||||
int Number(string name, int current, int min, int max)
|
||||
{
|
||||
if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current;
|
||||
n = Math.Clamp(n, min, max);
|
||||
if (n != current) changed.Add(name);
|
||||
return n;
|
||||
}
|
||||
bool Flag(string name, bool current)
|
||||
{
|
||||
if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current;
|
||||
bool f = v.GetBoolean();
|
||||
if (f != current) changed.Add(name);
|
||||
return f;
|
||||
}
|
||||
|
||||
string name = Text("name", config.Name, 60);
|
||||
if (name.Length == 0) return "The server name cannot be empty";
|
||||
config.Name = name;
|
||||
config.Description = Text("description", config.Description, 300);
|
||||
config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64);
|
||||
config.Public = Flag("isPublic", config.Public);
|
||||
config.RequireSteam = Flag("requireSteam", config.RequireSteam);
|
||||
config.PublicAddress = Text("publicAddress", config.PublicAddress, 200);
|
||||
config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240);
|
||||
config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600);
|
||||
|
||||
int before = changed.Count;
|
||||
config.Port = Number("port", config.Port, 1, 65535);
|
||||
config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled);
|
||||
config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535);
|
||||
config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60);
|
||||
config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy);
|
||||
config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60);
|
||||
if (changed.Count > before) restartNeeded = true;
|
||||
|
||||
foreach (string secret in new[] { "licenseKey", "steamWebApiKey" })
|
||||
{
|
||||
if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue;
|
||||
if (!owner) return "Only the owner can change keys";
|
||||
string value = v.GetString().Trim();
|
||||
if (value.Length > 200) return "Key too long";
|
||||
if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value;
|
||||
changed.Add(secret);
|
||||
restartNeeded = true;
|
||||
}
|
||||
|
||||
if (changed.Count == 0) return "Nothing changed";
|
||||
try { config.Save(configPath); }
|
||||
catch (Exception e) { return "Could not save server.json: " + e.Message; }
|
||||
string result = ConfigEditor.Apply(config, configPath, b, owner, by);
|
||||
HeartbeatSoon(); // Name/Beschreibung/Spielerzahl gleich an die Serverliste
|
||||
Log($"Settings changed by {by}: {string.Join(", ", changed)}");
|
||||
var overridden = changed.Where(c => config.EnvironmentOverrides.Any(e => e.Replace("_", "").Contains(c.ToUpperInvariant().Replace("ISPUBLIC", "PUBLIC")))).ToList();
|
||||
return "Settings saved" + (restartNeeded ? " - some changes need a restart" : "") +
|
||||
(overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : "");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen
|
||||
|
||||
const long MaxSaveZip = 64L * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Spielstand aus einer ZIP übernehmen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem
|
||||
/// Ordner in der ZIP) - Pfade aus der ZIP werden nie benutzt. Danach: Backup, laden, neuer Reset-Punkt, Welt leer,
|
||||
@@ -1291,41 +1149,12 @@ public sealed class GameServer
|
||||
/// </summary>
|
||||
public string ImportSaveZip(byte[] zip, string by)
|
||||
{
|
||||
var found = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(zip), System.IO.Compression.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 > MaxSaveZip || (total += entry.Length) > MaxSaveZip) 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";
|
||||
|
||||
string error = SaveStore.ExtractZip(zip, out var found);
|
||||
if (error != null) return error;
|
||||
try
|
||||
{
|
||||
BackupSave();
|
||||
Directory.CreateDirectory(savePath);
|
||||
foreach (string name in SaveFiles)
|
||||
{
|
||||
string target = Path.Combine(savePath, name);
|
||||
if (found.TryGetValue(name, out var data))
|
||||
{
|
||||
File.WriteAllBytes(target + ".tmp", data);
|
||||
File.Move(target + ".tmp", target, true);
|
||||
}
|
||||
else if (File.Exists(target)) File.Delete(target); // gehört zum alten Spielstand
|
||||
}
|
||||
SaveStore.WriteSave(DataDir, found);
|
||||
}
|
||||
catch (Exception e) { return "Could not write the save game: " + e.Message; }
|
||||
if (!LoadSave()) return "The uploaded save game could not be loaded - see the console";
|
||||
@@ -1340,19 +1169,7 @@ public sealed class GameServer
|
||||
}
|
||||
|
||||
/// <summary>Aktuellen Spielstand als ZIP.</summary>
|
||||
public byte[] ExportSaveZip()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||
{
|
||||
foreach (string name in SaveFiles)
|
||||
{
|
||||
string file = Path.Combine(savePath, name);
|
||||
if (File.Exists(file)) System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, file, name);
|
||||
}
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
public byte[] ExportSaveZip() => SaveStore.ExportZip(DataDir);
|
||||
|
||||
public object PanelBackups() => SaveBackups().Select(d => Path.GetFileName(d)).ToList();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user