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:
+24
-6
@@ -140,6 +140,8 @@ public sealed class GameServer
|
|||||||
}
|
}
|
||||||
SaveUpload saveUpload;
|
SaveUpload saveUpload;
|
||||||
int nextSaveRequestId = 1;
|
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;
|
double nextSaveSync;
|
||||||
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
|
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
|
||||||
const long MaxUploadBytes = 32L * 1024 * 1024;
|
const long MaxUploadBytes = 32L * 1024 * 1024;
|
||||||
@@ -204,6 +206,12 @@ public sealed class GameServer
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
saveFiles = loaded;
|
saveFiles = loaded;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string main = Path.Combine(savePath, "defaultES2File.txt");
|
||||||
|
if (File.Exists(main)) lastGameSaveUtc = File.GetLastWriteTimeUtc(main);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
NewGame = false;
|
NewGame = false;
|
||||||
Log("Loaded save game (" + saveFiles.Count + " files, " + saveFiles.Sum(f => f.Value.Length) / 1024 + " KB)");
|
Log("Loaded save game (" + saveFiles.Count + " files, " + saveFiles.Sum(f => f.Value.Length) / 1024 + " KB)");
|
||||||
return true;
|
return true;
|
||||||
@@ -369,7 +377,7 @@ public sealed class GameServer
|
|||||||
{
|
{
|
||||||
nextSaveSync = now + config.SaveSyncMinutes * 60.0;
|
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
|
// 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
|
// Hochladen dauert zu lange (Spieler weg, alte Mod ...): Wartende bekommen den bisherigen Spielstand
|
||||||
if (saveUpload != null && DateTime.UtcNow > saveUpload.Deadline)
|
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)));
|
c.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(isAdmin)));
|
||||||
|
|
||||||
// Spielstand schicken; nach dem Laden fragt der Client mit Ready nach dem Weltzustand.
|
// 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).
|
// Spielt schon jemand und ist der Spielstand alt, erst dessen aktuellen Spielstand holen (Schrauben, Teile ...
|
||||||
if (RequestSave("new player " + c.Name)) waitingForSave.Add(c);
|
// 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);
|
else SendSave(c);
|
||||||
|
|
||||||
Log($"{c.Name} joined (SteamID {c.SteamId}, {c.Conn.RemoteAddress}) - {JoinedCount}/{config.MaxPlayers}");
|
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.
|
/// 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.
|
/// Gibt false zurück, wenn niemand im Spiel ist - dann ist der gespeicherte Spielstand ohnehin aktuell.
|
||||||
/// </summary>
|
/// </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
|
if (saveUpload != null) return true; // läuft schon
|
||||||
var source = clients.Values
|
var source = clients.Values
|
||||||
.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosing && x.TrackPositionFrom != DateTime.MaxValue)
|
.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosing && x.TrackPositionFrom != DateTime.MaxValue)
|
||||||
.OrderBy(x => x.Id).FirstOrDefault();
|
.OrderBy(x => x.Id).FirstOrDefault();
|
||||||
if (source == null) return false;
|
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;
|
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})");
|
Log($"Asking {source.Name} for the current save game ({why})");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>MSCO.Server</RootNamespace>
|
<RootNamespace>MSCO.Server</RootNamespace>
|
||||||
<AssemblyName>MSCO.Server</AssemblyName>
|
<AssemblyName>MSCO.Server</AssemblyName>
|
||||||
<Version>2.10.4</Version>
|
<Version>2.10.5</Version>
|
||||||
<Company>MBM</Company>
|
<Company>MBM</Company>
|
||||||
<Product>MSCO Dedicated Server</Product>
|
<Product>MSCO Dedicated Server</Product>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ und neu starten.
|
|||||||
| `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) |
|
| `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) |
|
| `RemoteManagement` | `true` = der Besitzer kann den Server im MSCO Launcher verwalten (Befehle über den Keymaster) |
|
||||||
| `AutosaveSeconds` | wie oft der Weltzustand gespeichert wird |
|
| `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) |
|
| `SteamWebApiKey` | Steam-Anmeldetickets der Spieler bei Steam prüfen – **nötig für Admins** (kostenlos: https://steamcommunity.com/dev/apikey) |
|
||||||
| `BannedSteamIds` | gesperrte Spieler |
|
| `BannedSteamIds` | gesperrte Spieler |
|
||||||
| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen (nur mit `SteamWebApiKey`, siehe Sicherheit) |
|
| `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
|
## 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
|
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
|
(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).
|
nachgespielt. Der vorherige Spielstand liegt jeweils in `data/save_backups/` (die letzten 5).
|
||||||
|
|||||||
+19
-3
@@ -28,9 +28,18 @@ public sealed class ServerConfig
|
|||||||
public int AutosaveSeconds { get; set; } = 60;
|
public int AutosaveSeconds { get; set; } = 60;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// So oft (Minuten) lässt sich der Server von einem Spieler im Spiel den aktuellen Spielstand hochladen
|
/// 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>
|
/// </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>
|
/// <summary>Optional: Steam Web API Key, um Steam-Tickets der Spieler bei Steam zu prüfen.</summary>
|
||||||
public string SteamWebApiKey { get; set; } = "";
|
public string SteamWebApiKey { get; set; } = "";
|
||||||
/// <summary>Gesperrte SteamID64s.</summary>
|
/// <summary>Gesperrte SteamID64s.</summary>
|
||||||
@@ -72,11 +81,18 @@ public sealed class ServerConfig
|
|||||||
created = !File.Exists(path);
|
created = !File.Exists(path);
|
||||||
if (created)
|
if (created)
|
||||||
{
|
{
|
||||||
var fresh = new ServerConfig();
|
var fresh = new ServerConfig { ConfigVersion = CurrentConfigVersion };
|
||||||
fresh.Save(path);
|
fresh.Save(path);
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
var config = JsonSerializer.Deserialize<ServerConfig>(File.ReadAllText(path), Options) ?? new ServerConfig();
|
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
|
// 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
|
try { config.Save(path); } catch (Exception) { } // z.B. schreibgeschützt - dann eben nicht
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
+16
-1
@@ -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" })
|
foreach (var name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" })
|
||||||
File.WriteAllText(Path.Combine(dir, "save", name), "test " + name);
|
File.WriteAllText(Path.Combine(dir, "save", name), "test " + name);
|
||||||
string configPath = Path.Combine(dir, "server.json");
|
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);
|
config.Save(configPath);
|
||||||
|
|
||||||
// Host wie im echten Betrieb: besitzt Port + Web-Panel (gleicher Port wie das Spiel) und startet den Gameserver
|
// 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
|
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");
|
Console.WriteLine("== Join");
|
||||||
var alice = await TestClient.Join("Alice", 0, null);
|
var alice = await TestClient.Join("Alice", 0, null);
|
||||||
Check(alice.Welcome != null, "Alice gets Welcome");
|
Check(alice.Welcome != null, "Alice gets Welcome");
|
||||||
@@ -338,6 +352,7 @@ try
|
|||||||
var joinTask = TestClient.Join("Newcomer", 0, null);
|
var joinTask = TestClient.Join("Newcomer", 0, null);
|
||||||
var request = await inGame.WaitFor(MsgType.SaveRequest, 5000);
|
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, "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)
|
if (request != null)
|
||||||
{
|
{
|
||||||
int reqId = BitConverter.ToInt32(request, 0);
|
int reqId = BitConverter.ToInt32(request, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user