Files
MrMahoandClaude Opus 5.5 6e2ef3b570 Server 2.10.5: far fewer save freezes
Saving makes the game of the asked player freeze for seconds. Joining now asks
for a fresh save only if the stored one is older than JoinSaveMaxAgeMinutes (15)
and something changed - otherwise the stored save plus the world state; regular
saves every 30 minutes (old untouched default 10 is migrated once) and marked
not urgent, so the game can wait for a calm moment (60 s deadline). 3 new tests (168).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:08:56 +02:00

178 lines
10 KiB
C#

using System.Text.Json;
namespace MSCO.Server;
/// <summary>server.json - wird beim ersten Start mit Standardwerten angelegt.</summary>
public sealed class ServerConfig
{
public string LicenseKey { get; set; } = "";
public string Name { get; set; } = "My MSCO Server";
public string Description { get; set; } = "";
/// <summary>
/// Eigene Tags für die Serverliste (z.B. "deutsch", "roleplay"), höchstens 6. "official" und "featured" vergeben
/// nur die MSCO-Admins im Keymaster.
/// </summary>
public List<string> Tags { get; set; } = new();
public int Port { get; set; } = 7777;
/// <summary>
/// Adresse, unter der Spieler den Server erreichen (IP oder Domain). Leer = die IP, von der
/// sich der Server beim Master meldet. Nötig z.B. wenn Master und Server im selben Docker-Netz laufen.
/// </summary>
public string PublicAddress { get; set; } = "";
public int MaxPlayers { get; set; } = 8;
/// <summary>In der Serverliste anzeigen.</summary>
public bool Public { get; set; } = true;
/// <summary>Nur Spieler mit Steam zulassen (false = auch ohne Steam, "Non-Steam"-Server).</summary>
public bool RequireSteam { get; set; } = true;
/// <summary>Weltzustand so oft auf die Festplatte schreiben.</summary>
public int AutosaveSeconds { get; set; } = 60;
/// <summary>
/// So oft (Minuten) lässt sich der Server von einem Spieler im Spiel den aktuellen Spielstand hochladen
/// (Schrauben, Teile, alles wie im Spiel gespeichert). Speichern lässt das Spiel dieses Spielers einige Sekunden
/// hängen - deshalb nicht zu oft. 0 = nur beim Beitreten.
/// </summary>
public int SaveSyncMinutes { get; set; } = 30;
/// <summary>
/// Beim Beitreten nur dann frisch speichern lassen, wenn der letzte Spielstand älter ist (Minuten) - sonst bekommt der
/// Neue den gespeicherten Stand und die seitdem passierten Änderungen. 0 = bei jedem Beitritt frisch speichern.
/// </summary>
public int JoinSaveMaxAgeMinutes { get; set; } = 15;
/// <summary>Version der Einstellungen (für einmalige Anpassungen alter server.json).</summary>
public int ConfigVersion { get; set; } = 0;
public const int CurrentConfigVersion = 2;
/// <summary>Optional: Steam Web API Key, um Steam-Tickets der Spieler bei Steam zu prüfen.</summary>
public string SteamWebApiKey { get; set; } = "";
/// <summary>Gesperrte SteamID64s.</summary>
public List<string> BannedSteamIds { get; set; } = new();
/// <summary>Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.</summary>
public List<string> Admins { get; set; } = new();
// ---------------------------------------------------------------- Web-Panel
/// <summary>Web-Panel einschalten.</summary>
public bool PanelEnabled { get; set; } = true;
/// <summary>
/// Verwalten aus dem MSCO Launcher: wer den Server dort mit dem Lizenzschlüssel verknüpft hat, kann über den Keymaster
/// Befehle schicken (say, kick, ban, save, backup, restart, mods reload, status, players). false = aus.
/// </summary>
public bool RemoteManagement { get; set; } = true;
/// <summary>0 = derselbe Port wie der Gameserver (Browser und Spiel werden automatisch unterschieden), sonst eigener Port.</summary>
public int PanelPort { get; set; } = 0;
/// <summary>Nur bei eigenem PanelPort: Adresse, auf der gelauscht wird ("0.0.0.0" = alle, "127.0.0.1" = nur lokal, z.B. hinter einem Proxy).</summary>
public string PanelBindAddress { get; set; } = "0.0.0.0";
/// <summary>Hinter einem Reverse-Proxy: X-Forwarded-For/-Proto/-Prefix übernehmen (echte IP, HTTPS, Unterpfad).</summary>
public bool PanelTrustProxy { get; set; } = false;
/// <summary>Unterpfad, unter dem das Panel läuft, z.B. "/panel" (leer = direkt unter /).</summary>
public string PanelBasePath { get; set; } = "";
/// <summary>
/// HTTPS im Panel. Auf dem Spiel-Port immer verfügbar (https://&lt;server&gt;:&lt;port&gt;/); true = Browser, die von
/// außen per HTTP kommen, werden auf HTTPS umgeleitet (Passwörter nie unverschlüsselt). Bei eigenem PanelPort spricht
/// dieser nur mit eigenem Zertifikat (PanelCertificatePath) HTTPS. Hinter einem Reverse-Proxy (PanelTrustProxy) macht
/// der Proxy HTTPS.
/// </summary>
public bool PanelHttps { get; set; } = true;
/// <summary>Eigenes Zertifikat (PFX, Pfad absolut oder relativ zu data/) - leer = selbst signiertes in data/panel_cert.pfx.</summary>
public string PanelCertificatePath { get; set; } = "";
public string PanelCertificatePassword { get; set; } = "";
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
public static ServerConfig LoadOrCreate(string path, out bool created)
{
created = !File.Exists(path);
if (created)
{
var fresh = new ServerConfig { ConfigVersion = CurrentConfigVersion };
fresh.Save(path);
return fresh;
}
var config = JsonSerializer.Deserialize<ServerConfig>(File.ReadAllText(path), Options) ?? new ServerConfig();
if (config.ConfigVersion < 2)
{
// Früherer Standard: alle 10 Minuten speichern lassen - das ließ einen Spieler alle 10 Minuten einige Sekunden
// hängen. Wer den Standard nie geändert hat, bekommt den neuen (30).
if (config.SaveSyncMinutes == 10) config.SaveSyncMinutes = 30;
config.ConfigVersion = CurrentConfigVersion;
}
// Neue Einstellungen (z.B. Admins) auch in ältere server.json schreiben, damit man sie sieht
try { config.Save(path); } catch (Exception) { } // z.B. schreibgeschützt - dann eben nicht
return config;
}
public void Save(string path)
{
File.WriteAllText(path, JsonSerializer.Serialize(this, Options));
PanelTls.Restrict(path); // enthält den Lizenzschlüssel und Passwörter (Linux: nur der Server-Benutzer)
}
/// <summary>
/// Umgebungsvariablen überschreiben server.json (praktisch für Docker/systemd):
/// MSCO_LICENSE_KEY, MSCO_NAME, MSCO_DESCRIPTION, MSCO_PORT, MSCO_MAX_PLAYERS, MSCO_PUBLIC,
/// MSCO_PUBLIC_ADDRESS, MSCO_STEAM_WEB_API_KEY (die Master-Adresse ist fest, siehe MasterServer)
/// </summary>
/// <returns>Namen der gesetzten Umgebungsvariablen (fürs Log, damit klar ist, woher ein Wert kommt).</returns>
/// <summary>Per Umgebungsvariable gesetzte Einstellungen (nicht in server.json gespeichert).</summary>
[System.Text.Json.Serialization.JsonIgnore]
public List<string> EnvironmentOverrides { get; private set; } = new();
public List<string> ApplyEnvironment()
{
var used = new List<string>();
EnvironmentOverrides = used;
string Take(string name, string current)
{
string value = Env(name);
if (value == null) return current;
used.Add(name);
return value;
}
LicenseKey = Take("MSCO_LICENSE_KEY", LicenseKey);
Name = Take("MSCO_NAME", Name);
Description = Take("MSCO_DESCRIPTION", Description);
PublicAddress = Take("MSCO_PUBLIC_ADDRESS", PublicAddress);
if (Env("MSCO_TAGS") is string tags) { Tags = NormalizeTags(tags.Split(','), out _); used.Add("MSCO_TAGS"); }
SteamWebApiKey = Take("MSCO_STEAM_WEB_API_KEY", SteamWebApiKey);
if (int.TryParse(Env("MSCO_PORT"), out int port)) { Port = port; used.Add("MSCO_PORT"); }
if (int.TryParse(Env("MSCO_MAX_PLAYERS"), out int max)) { MaxPlayers = max; used.Add("MSCO_MAX_PLAYERS"); }
if (bool.TryParse(Env("MSCO_PUBLIC"), out bool isPublic)) { Public = isPublic; used.Add("MSCO_PUBLIC"); }
if (bool.TryParse(Env("MSCO_REQUIRE_STEAM"), out bool requireSteam)) { RequireSteam = requireSteam; used.Add("MSCO_REQUIRE_STEAM"); }
if (bool.TryParse(Env("MSCO_PANEL_ENABLED"), out bool panel)) { PanelEnabled = panel; used.Add("MSCO_PANEL_ENABLED"); }
if (int.TryParse(Env("MSCO_PANEL_PORT"), out int panelPort)) { PanelPort = panelPort; used.Add("MSCO_PANEL_PORT"); }
if (bool.TryParse(Env("MSCO_PANEL_TRUST_PROXY"), out bool trustProxy)) { PanelTrustProxy = trustProxy; used.Add("MSCO_PANEL_TRUST_PROXY"); }
PanelBindAddress = Take("MSCO_PANEL_BIND", PanelBindAddress);
PanelBasePath = Take("MSCO_PANEL_BASE_PATH", PanelBasePath);
if (bool.TryParse(Env("MSCO_PANEL_HTTPS"), out bool panelHttps)) { PanelHttps = panelHttps; used.Add("MSCO_PANEL_HTTPS"); }
PanelCertificatePath = Take("MSCO_PANEL_CERT", PanelCertificatePath);
PanelCertificatePassword = Take("MSCO_PANEL_CERT_PASSWORD", PanelCertificatePassword);
return used;
}
public const int MaxTags = 6;
/// <summary>Diese Tags vergeben nur die MSCO-Admins (Keymaster), nicht der Server selbst.</summary>
public static readonly string[] ReservedTags = { "official", "featured" };
/// <summary>Wie der Keymaster: "Deutsch, Role Play!" -> ["deutsch", "role-play"]; reservierte fallen weg.</summary>
public static List<string> NormalizeTags(IEnumerable<string> input, out List<string> reserved)
{
reserved = new List<string>();
var tags = new List<string>();
foreach (string raw in input ?? Array.Empty<string>())
{
string t = (raw ?? "").Trim().ToLowerInvariant().Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
t = System.Text.RegularExpressions.Regex.Replace(t, @"\s+", "-");
t = System.Text.RegularExpressions.Regex.Replace(t, "[^a-z0-9-]", "").Trim('-');
if (t.Length > 20) t = t[..20].Trim('-');
if (t.Length == 0 || tags.Contains(t)) continue;
if (ReservedTags.Contains(t)) { if (!reserved.Contains(t)) reserved.Add(t); continue; }
if (tags.Count < MaxTags) tags.Add(t);
}
return tags;
}
static string Env(string name)
{
string value = Environment.GetEnvironmentVariable(name);
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}