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>
This commit is contained in:
2026-10-04 00:08:56 +02:00
co-authored by Claude Opus 5.5
parent 4cc71aa079
commit 6e2ef3b570
5 changed files with 63 additions and 13 deletions
+24 -6
View File
@@ -140,6 +140,8 @@ public sealed class GameServer
}
SaveUpload saveUpload;
int nextSaveRequestId = 1;
/// <summary>Wann der aktuelle Spielstand vom Spiel geschrieben wurde (Datei-Zeit) - bestimmt, ob ein Beitritt frisch speichern lässt.</summary>
DateTime lastGameSaveUtc = DateTime.MinValue;
double nextSaveSync;
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
const long MaxUploadBytes = 32L * 1024 * 1024;
@@ -204,6 +206,12 @@ public sealed class GameServer
return false;
}
saveFiles = loaded;
try
{
string main = Path.Combine(savePath, "defaultES2File.txt");
if (File.Exists(main)) lastGameSaveUtc = File.GetLastWriteTimeUtc(main);
}
catch { }
NewGame = false;
Log("Loaded save game (" + saveFiles.Count + " files, " + saveFiles.Sum(f => f.Value.Length) / 1024 + " KB)");
return true;
@@ -369,7 +377,7 @@ public sealed class GameServer
{
nextSaveSync = now + config.SaveSyncMinutes * 60.0;
// Nur wenn sich seit dem letzten Spielstand etwas geändert hat - Speichern lässt das Spiel kurz hängen
if (world.ChangesSinceSave > 0) RequestSave("regular save");
if (world.ChangesSinceSave > 0) RequestSave("regular save", urgent: false);
}
// Hochladen dauert zu lange (Spieler weg, alte Mod ...): Wartende bekommen den bisherigen Spielstand
if (saveUpload != null && DateTime.UtcNow > saveUpload.Deadline)
@@ -697,8 +705,12 @@ public sealed class GameServer
c.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(isAdmin)));
// Spielstand schicken; nach dem Laden fragt der Client mit Ready nach dem Weltzustand.
// Spielt schon jemand, erst dessen aktuellen Spielstand holen (Schrauben, Teile ... genau wie jetzt im Spiel).
if (RequestSave("new player " + c.Name)) waitingForSave.Add(c);
// Spielt schon jemand und ist der Spielstand alt, erst dessen aktuellen Spielstand holen (Schrauben, Teile ...
// genau wie jetzt im Spiel). Speichern lässt das Spiel dieses Spielers einige Sekunden hängen - bei einem
// frischen Spielstand bekommt der Neue ihn plus die seitdem passierten Änderungen (Weltzustand).
bool saveIsFresh = world.ChangesSinceSave == 0
|| (config.JoinSaveMaxAgeMinutes > 0 && DateTime.UtcNow - lastGameSaveUtc < TimeSpan.FromMinutes(config.JoinSaveMaxAgeMinutes));
if (!saveIsFresh && RequestSave("new player " + c.Name)) waitingForSave.Add(c);
else SendSave(c);
Log($"{c.Name} joined (SteamID {c.SteamId}, {c.Conn.RemoteAddress}) - {JoinedCount}/{config.MaxPlayers}");
@@ -782,16 +794,22 @@ public sealed class GameServer
/// Einen Spieler, der schon im Spiel ist (der am längsten verbundene), um seinen aktuellen Spielstand bitten.
/// Gibt false zurück, wenn niemand im Spiel ist - dann ist der gespeicherte Spielstand ohnehin aktuell.
/// </summary>
bool RequestSave(string why)
/// <param name="urgent">false = Routine: das Spiel darf auf einen ruhigen Moment warten (nicht während schneller Fahrt).</param>
bool RequestSave(string why, bool urgent = true)
{
if (saveUpload != null) return true; // läuft schon
var source = clients.Values
.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosing && x.TrackPositionFrom != DateTime.MaxValue)
.OrderBy(x => x.Id).FirstOrDefault();
if (source == null) return false;
saveUpload = new SaveUpload { Id = nextSaveRequestId++, FromClient = source.Id, Deadline = DateTime.UtcNow.AddSeconds(SaveUploadTimeoutSeconds) };
saveUpload = new SaveUpload
{
Id = nextSaveRequestId++, FromClient = source.Id,
Deadline = DateTime.UtcNow.AddSeconds(urgent ? SaveUploadTimeoutSeconds : Math.Max(SaveUploadTimeoutSeconds, 60))
};
int id = saveUpload.Id;
source.Conn.Send(MsgType.SaveRequest, Wire.Build(w => w.Write(id)));
// Ältere Mods lesen nur die ID - das zusätzliche Byte stört sie nicht
source.Conn.Send(MsgType.SaveRequest, Wire.Build(w => { w.Write(id); w.Write(urgent); }));
Log($"Asking {source.Name} for the current save game ({why})");
return true;
}
+1 -1
View File
@@ -7,7 +7,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MSCO.Server</RootNamespace>
<AssemblyName>MSCO.Server</AssemblyName>
<Version>2.10.4</Version>
<Version>2.10.5</Version>
<Company>MBM</Company>
<Product>MSCO Dedicated Server</Product>
<InvariantGlobalization>true</InvariantGlobalization>
+3 -2
View File
@@ -116,7 +116,8 @@ und neu starten.
| `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) |
| `RemoteManagement` | `true` = der Besitzer kann den Server im MSCO Launcher verwalten (Befehle über den Keymaster) |
| `AutosaveSeconds` | wie oft der Weltzustand gespeichert wird |
| `SaveSyncMinutes` | wie oft (Minuten) sich der Server von einem Spieler im Spiel den **aktuellen Spielstand** hochladen lässt (Standard 10, zusätzlich bei jedem Beitritt; nur wenn sich etwas geändert hat; 0 = nur beim Beitritt) |
| `SaveSyncMinutes` | wie oft (Minuten) sich der Server von einem Spieler im Spiel den **aktuellen Spielstand** hochladen lässt (Standard 30; nur wenn sich etwas geändert hat; 0 = nur beim Beitritt). Speichern lässt das Spiel dieses Spielers einige Sekunden hängen – das Spiel wartet dafür einen ruhigen Moment ab (nicht in schneller Fahrt) |
| `JoinSaveMaxAgeMinutes` | beim Beitritt nur dann frisch speichern lassen, wenn der letzte Spielstand älter ist (Standard 15; 0 = bei jedem Beitritt). Sonst bekommt der Neue den gespeicherten Stand plus alle Änderungen seitdem |
| `SteamWebApiKey` | Steam-Anmeldetickets der Spieler bei Steam prüfen – **nötig für Admins** (kostenlos: https://steamcommunity.com/dev/apikey) |
| `BannedSteamIds` | gesperrte Spieler |
| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen (nur mit `SteamWebApiKey`, siehe Sicherheit) |
@@ -267,7 +268,7 @@ Der Chat landet auch im Server-Log.
## Was der Server speichert
**Aktueller Spielstand statt Nachspielen:** Tritt jemand bei (und alle `SaveSyncMinutes`), bittet der Server einen
**Aktueller Spielstand statt Nachspielen:** Tritt jemand bei (wenn der letzte Spielstand älter als `JoinSaveMaxAgeMinutes` ist) und alle `SaveSyncMinutes`, bittet der Server einen
Spieler, der schon im Spiel ist, zu speichern und den Spielstand hochzuladen – genau wie das Spiel selbst speichert
(Schrauben, angebaute Teile, alles). Neue Spieler bekommen diesen Stand; alte Aktionen werden dann nicht mehr
nachgespielt. Der vorherige Spielstand liegt jeweils in `data/save_backups/` (die letzten 5).
+19 -3
View File
@@ -28,9 +28,18 @@ public sealed class ServerConfig
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). Zusätzlich immer, wenn jemand beitritt. 0 = nur beim Beitreten.
/// (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; } = 10;
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>
@@ -72,11 +81,18 @@ public sealed class ServerConfig
created = !File.Exists(path);
if (created)
{
var fresh = new ServerConfig();
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;
+16 -1
View File
@@ -12,7 +12,7 @@ Directory.CreateDirectory(Path.Combine(dir, "save"));
foreach (var name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" })
File.WriteAllText(Path.Combine(dir, "save", name), "test " + name);
string configPath = Path.Combine(dir, "server.json");
var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } };
var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId }, JoinSaveMaxAgeMinutes = 0 };
config.Save(configPath);
// Host wie im echten Betrieb: besitzt Port + Web-Panel (gleicher Port wie das Spiel) und startet den Gameserver
@@ -32,6 +32,20 @@ void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.Writ
try
{
Console.WriteLine("== Settings migration");
{
string oldCfg = Path.Combine(Path.GetTempPath(), "msco-oldcfg-" + Guid.NewGuid().ToString("N") + ".json");
File.WriteAllText(oldCfg, "{ \"Name\": \"Old\", \"SaveSyncMinutes\": 10 }");
var migrated = ServerConfig.LoadOrCreate(oldCfg, out _);
File.WriteAllText(oldCfg, "{ \"Name\": \"Custom\", \"SaveSyncMinutes\": 5 }");
var custom = ServerConfig.LoadOrCreate(oldCfg, out _);
var again = ServerConfig.LoadOrCreate(oldCfg, out _);
File.Delete(oldCfg);
Check(migrated.SaveSyncMinutes == 30 && migrated.ConfigVersion == ServerConfig.CurrentConfigVersion,
"Old default 'save every 10 minutes' becomes 30 (less freezing)");
Check(custom.SaveSyncMinutes == 5 && again.SaveSyncMinutes == 5, "A value the owner chose is kept");
}
Console.WriteLine("== Join");
var alice = await TestClient.Join("Alice", 0, null);
Check(alice.Welcome != null, "Alice gets Welcome");
@@ -338,6 +352,7 @@ try
var joinTask = TestClient.Join("Newcomer", 0, null);
var request = await inGame.WaitFor(MsgType.SaveRequest, 5000);
Check(request != null, "Server asks the player in the game for the current save");
Check(request != null && request.Length == 5 && request[4] == 1, "A save request for a joining player is marked urgent");
if (request != null)
{
int reqId = BitConverter.ToInt32(request, 0);