diff --git a/GameServer.cs b/GameServer.cs index c108d84..30bb8c5 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -140,6 +140,8 @@ public sealed class GameServer } SaveUpload saveUpload; int nextSaveRequestId = 1; + /// Wann der aktuelle Spielstand vom Spiel geschrieben wurde (Datei-Zeit) - bestimmt, ob ein Beitritt frisch speichern lässt. + DateTime lastGameSaveUtc = DateTime.MinValue; double nextSaveSync; readonly List 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. /// - bool RequestSave(string why) + /// false = Routine: das Spiel darf auf einen ruhigen Moment warten (nicht während schneller Fahrt). + 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; } diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj index 1a3925d..3bafd2f 100644 --- a/MSCO.Server.csproj +++ b/MSCO.Server.csproj @@ -7,7 +7,7 @@ enable MSCO.Server MSCO.Server - 2.10.4 + 2.10.5 MBM MSCO Dedicated Server true diff --git a/README.md b/README.md index cf6eaf9..ae7145b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/ServerConfig.cs b/ServerConfig.cs index b277ccf..021f984 100644 --- a/ServerConfig.cs +++ b/ServerConfig.cs @@ -28,9 +28,18 @@ public sealed class ServerConfig public int AutosaveSeconds { get; set; } = 60; /// /// 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. /// - public int SaveSyncMinutes { get; set; } = 10; + public int SaveSyncMinutes { get; set; } = 30; + /// + /// 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. + /// + public int JoinSaveMaxAgeMinutes { get; set; } = 15; + /// Version der Einstellungen (für einmalige Anpassungen alter server.json). + public int ConfigVersion { get; set; } = 0; + public const int CurrentConfigVersion = 2; /// Optional: Steam Web API Key, um Steam-Tickets der Spieler bei Steam zu prüfen. public string SteamWebApiKey { get; set; } = ""; /// Gesperrte SteamID64s. @@ -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(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; diff --git a/Tests/Program.cs b/Tests/Program.cs index 141f731..bac3b27 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -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);