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;
|
||||
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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user