From 2deb6d2d1e5984b916cbe68c731f252d682a3cce Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sun, 27 Sep 2026 11:15:45 +0200 Subject: [PATCH] Server gets the current save game from a player in the game (on join and every few minutes) instead of replaying actions; vehicle fire/smoke synced, also for parked cars Co-Authored-By: Claude Opus 5.5 --- GameServer.cs | 155 +++++++++++++++++++++++++++++++++++++++++++++-- Protocol.cs | 5 +- README.md | 6 ++ ServerConfig.cs | 5 ++ Tests/Program.cs | 44 +++++++++++++- WorldState.cs | 15 +++++ 6 files changed, 222 insertions(+), 8 deletions(-) diff --git a/GameServer.cs b/GameServer.cs index 8f30918..8ada779 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -61,6 +61,25 @@ public sealed class GameServer static readonly TimeSpan ClientTimeout = TimeSpan.FromSeconds(30); bool running = true; + // ---------------------------------------------------------------- Spielstand vom Spiel holen + // Statt Aktionen (Schrauben, Montage ...) auf einen alten Spielstand nachzuspielen, speichert ein Spieler im Spiel + // und lädt den Spielstand hoch. Neue Spieler bekommen damit genau die aktuelle Welt. + sealed class SaveUpload + { + public int Id; + public int FromClient; + public DateTime Deadline; + public readonly Dictionary Files = new(); + } + SaveUpload saveUpload; + int nextSaveRequestId = 1; + double nextSaveSync; + readonly List waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten + const long MaxUploadBytes = 32L * 1024 * 1024; + const int KeepSaveBackups = 5; + /// So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten. + public double SaveUploadTimeoutSeconds { get; set; } = 15; + /// Ohne Master-Server (nur für automatische Tests): keine Heartbeats, keine Serverliste. readonly bool offline; @@ -216,6 +235,20 @@ public sealed class GameServer if (world.Dirty) SaveWorld(); } + // Regelmäßig den aktuellen Spielstand vom Spiel holen + if (config.SaveSyncMinutes > 0 && now >= nextSaveSync) + { + nextSaveSync = now + config.SaveSyncMinutes * 60.0; + RequestSave("regular save"); + } + // Hochladen dauert zu lange (Spieler weg, alte Mod ...): Wartende bekommen den bisherigen Spielstand + if (saveUpload != null && DateTime.UtcNow > saveUpload.Deadline) + { + Log("Save upload timed out - using the stored save game"); + saveUpload = null; + SendSaveToWaiting(); + } + if (heartbeat != null && heartbeat.IsCompleted) { var result = heartbeat.Result; @@ -301,6 +334,14 @@ public sealed class GameServer if (m.Payload.Length > 0 && m.Payload.Length <= 16) c.Conn.Send(MsgType.Pong, m.Payload); return; + case MsgType.SaveUploadPart: + ReceiveSavePart(c, m.Reader()); + return; + + case MsgType.SaveUploadDone: + FinishSaveUpload(c, m.Reader()); + return; + case MsgType.AdminListRequest: if (c.State != ClientState.Joined) return; if (!c.IsAdmin) { c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write("You are not an admin on this server"))); return; } @@ -435,11 +476,10 @@ public sealed class GameServer bool isAdmin = c.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 - c.Conn.Send(MsgType.SaveBegin); - foreach (var f in saveFiles) - c.Conn.Send(MsgType.SaveFile, Wire.Build(w => { w.Write(f.Key); w.Write(f.Value.Length); w.Write(f.Value); })); - c.Conn.Send(MsgType.StartGame, Wire.Build(w => w.Write(false))); + // 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); + else SendSave(c); Log($"{c.Name} joined (SteamID {c.SteamId}, {c.Conn.RemoteAddress}) - {JoinedCount}/{config.MaxPlayers}"); HeartbeatSoon(); @@ -470,6 +510,111 @@ public sealed class GameServer } /// Chatnachricht vom Server an alle (Absender-ID 0 = "Server"). + void SendSave(Client c) + { + if (c.Conn.IsClosed) return; + c.Conn.Send(MsgType.SaveBegin); + foreach (var f in saveFiles) + c.Conn.Send(MsgType.SaveFile, Wire.Build(w => { w.Write(f.Key); w.Write(f.Value.Length); w.Write(f.Value); })); + c.Conn.Send(MsgType.StartGame, Wire.Build(w => w.Write(false))); + } + + void SendSaveToWaiting() + { + foreach (var w in waitingForSave) SendSave(w); + waitingForSave.Clear(); + } + + /// + /// 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) + { + 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) }; + int id = saveUpload.Id; + source.Conn.Send(MsgType.SaveRequest, Wire.Build(w => w.Write(id))); + Log($"Asking {source.Name} for the current save game ({why})"); + return true; + } + + void ReceiveSavePart(Client c, BinaryReader r) + { + int id = r.ReadInt32(); + if (saveUpload == null || saveUpload.Id != id || saveUpload.FromClient != c.Id) return; // nicht angefordert + string name = r.ReadString(); + int total = r.ReadInt32(), offset = r.ReadInt32(), length = r.ReadInt32(); + if (!SaveFiles.Contains(name) || total < 0 || total > MaxUploadBytes || offset < 0 || length < 0 || offset + length > total) + { + Log($"Rejected save upload from {c.Name}: invalid part"); + saveUpload = null; + SendSaveToWaiting(); + return; + } + if (!saveUpload.Files.TryGetValue(name, out var data)) + { + if (saveUpload.Files.Values.Sum(f => (long)f.Length) + total > MaxUploadBytes) { saveUpload = null; SendSaveToWaiting(); return; } + saveUpload.Files[name] = data = new byte[total]; + } + if (data.Length != total) return; + byte[] chunk = r.ReadBytes(length); + Buffer.BlockCopy(chunk, 0, data, offset, chunk.Length); + saveUpload.Deadline = DateTime.UtcNow.AddSeconds(SaveUploadTimeoutSeconds); // es kommt noch was + } + + void FinishSaveUpload(Client c, BinaryReader r) + { + int id = r.ReadInt32(); + if (saveUpload == null || saveUpload.Id != id || saveUpload.FromClient != c.Id) return; + var upload = saveUpload; + saveUpload = null; + if (!upload.Files.ContainsKey("defaultES2File.txt")) + { + Log($"Save upload from {c.Name} is incomplete - keeping the stored save game"); + SendSaveToWaiting(); + return; + } + try + { + BackupSave(); + Directory.CreateDirectory(savePath); + foreach (var f in upload.Files) + { + string target = Path.Combine(savePath, f.Key), tmp = target + ".tmp"; + File.WriteAllBytes(tmp, f.Value); + File.Move(tmp, target, true); + } + if (LoadSave()) + { + world.ResetForNewSave(); + SaveWorld(); + Log($"Save game updated from {c.Name} ({upload.Files.Count} files) - replayed actions are no longer needed"); + } + } + catch (Exception e) + { + Log("Could not store the uploaded save game: " + e.Message); + } + SendSaveToWaiting(); + } + + /// Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten. + void BackupSave() + { + if (!Directory.Exists(savePath)) return; + string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups"); + string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss")); + Directory.CreateDirectory(target); + foreach (string file in Directory.GetFiles(savePath)) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true); + foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepSaveBackups)) + try { Directory.Delete(old, true); } catch { } + } + /// /// Alle Spieler neu verbinden lassen (nach reset-world / reload-save): sie sehen die Meldung, gehen kurz ins Menü /// und laden dann den neuen Stand. Sonst liefen sie mit der alten Welt weiter. diff --git a/Protocol.cs b/Protocol.cs index b7eba74..3570e05 100644 --- a/Protocol.cs +++ b/Protocol.cs @@ -37,7 +37,10 @@ public enum MsgType : byte Teleport = 30, // Admin holt einen Spieler zu sich: 3 float Position Pong = 31, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen) SpawnItem = 32, // Admin spawnt ein Item: string Item-Name, 3 float Position, 4 float Drehung - Reconnect = 33 // Server -> Client: string Meldung; Client geht ins Menü und verbindet sich neu (z.B. Welt zurückgesetzt) + Reconnect = 33, // Server -> Client: string Meldung; Client geht ins Menü und verbindet sich neu (z.B. Welt zurückgesetzt) + SaveRequest = 34, // Server -> Client: int Anfrage; bitte im Spiel speichern und den Spielstand hochladen + SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes + SaveUploadDone = 36 // Client -> Server: int Anfrage, int Anzahl Dateien } public readonly record struct Message(MsgType Type, byte[] Payload) diff --git a/README.md b/README.md index 2efd6d7..ba29662 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ und neu starten. | `RequireSteam` | `true` = nur Spieler mit Steam; `false` = Non-Steam-Server (Spieler werden über den Namen erkannt) | | `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) | | `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 5, zusätzlich bei jedem Beitritt; 0 = nur beim Beitritt) | | `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) | @@ -140,6 +141,11 @@ 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 +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). + - `save/` – der Ausgangs-Spielstand, den jeder neue Spieler bekommt. - `world.json` – alles, was seitdem passiert ist: Positionen bewegter Objekte, Schraub-/Montage-Aktionen, Geld, Türen/Tore, Fahrzeugzustand (Tank, Öl, Schrauben, Verschleiß …) und die letzte Position jedes Spielers. diff --git a/ServerConfig.cs b/ServerConfig.cs index 39c2bac..6b270ec 100644 --- a/ServerConfig.cs +++ b/ServerConfig.cs @@ -21,6 +21,11 @@ public sealed class ServerConfig public bool RequireSteam { get; set; } = true; /// Weltzustand so oft auf die Festplatte schreiben. 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. + /// + public int SaveSyncMinutes { get; set; } = 5; /// Optional: Steam Web API Key, um Steam-Tickets der Spieler bei Steam zu prüfen. public string SteamWebApiKey { get; set; } = ""; /// Gesperrte SteamID64s. diff --git a/Tests/Program.cs b/Tests/Program.cs index 04e4d38..81077f3 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -16,6 +16,7 @@ var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, config.Save(configPath); var server = new GameServer(config, dir, configPath, "Tester", offline: true); +server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; } var serverThread = new Thread(server.Run) { IsBackground = true }; serverThread.Start(); @@ -176,6 +177,37 @@ try Check(rec != null && new BinaryReader(new MemoryStream(rec)).ReadString().Contains("reset"), "Players get the 'world is being reset' reconnect message"); await Task.Delay(300); Check(r1.IsClosedByServer(), "Their connection is closed so they load the fresh world"); + + Console.WriteLine("== Joining gets the CURRENT save game from a player in the game"); + var inGame = await TestClient.Join("InGame", 0, null); + inGame.Send(MsgType.Ready, Array.Empty()); + await inGame.WaitFor(MsgType.WorldSnapshot); + inGame.Send(MsgType.FsmEvent, Wire.Build(w => { w.Write("SATSUMA|bolt#0"); w.Write("Screw"); w.Write("TIGHTEN"); w.Write("Tight"); w.Write(0f); w.Write(0f); w.Write(0f); })); + await Task.Delay(200); + 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"); + if (request != null) + { + int reqId = BitConverter.ToInt32(request, 0); + byte[] fresh = System.Text.Encoding.UTF8.GetBytes(new string('S', 300_000) + "CURRENT-SAVE"); + for (int off = 0; off < fresh.Length; off += 100_000) + { + int len = Math.Min(100_000, fresh.Length - off); + inGame.Send(MsgType.SaveUploadPart, Wire.Build(w => { w.Write(reqId); w.Write("defaultES2File.txt"); w.Write(fresh.Length); w.Write(off); w.Write(len); w.Write(fresh, off, len); })); + } + inGame.Send(MsgType.SaveUploadDone, Wire.Build(w => { w.Write(reqId); w.Write(1); })); + } + var newcomer = await joinTask; + Check(newcomer.Files.TryGetValue("defaultES2File.txt", out var got) && System.Text.Encoding.UTF8.GetString(got).EndsWith("CURRENT-SAVE"), + "Newcomer receives exactly the uploaded current save (" + (got?.Length ?? 0) + " bytes, sent in parts)"); + newcomer.Send(MsgType.Ready, Array.Empty()); + var fresh2 = Snapshot.Parse(await newcomer.WaitFor(MsgType.WorldSnapshot)); + Check(fresh2.Ok && fresh2.Info.Contains("0 events"), "Replayed actions are cleared - they are in the save now (" + fresh2.Info + ")"); + Check(Directory.Exists(Path.Combine(dir, "save_backups")) && Directory.GetDirectories(Path.Combine(dir, "save_backups")).Length > 0, + "The previous save game is kept as a backup"); + inGame.Close(); + newcomer.Close(); } catch (Exception e) { @@ -255,6 +287,7 @@ sealed class TestClient public int SaveFiles; public bool GotStartGame; public string RejectReason; + public Dictionary Files = new(); public static async Task Join(string name, ulong steamId, byte[] ticket, bool expectReject = false) { @@ -280,8 +313,15 @@ sealed class TestClient if (c.Welcome != null) c.Id = BitConverter.ToInt32(c.Welcome, 0); var admin = await c.WaitFor(MsgType.AdminStatus); if (admin != null) c.AdminStatus = admin[0] != 0; - await c.WaitFor(MsgType.SaveBegin); - while (await c.WaitFor(MsgType.SaveFile, 1500) != null) c.SaveFiles++; + await c.WaitFor(MsgType.SaveBegin, 20000); // kann warten, bis ein anderer Spieler den aktuellen Spielstand hochlädt + byte[] sf; + while ((sf = await c.WaitFor(MsgType.SaveFile, 1500)) != null) + { + c.SaveFiles++; + var fr = new BinaryReader(new MemoryStream(sf)); + string fname = fr.ReadString(); + c.Files[fname] = fr.ReadBytes(fr.ReadInt32()); + } c.GotStartGame = await c.WaitFor(MsgType.StartGame, 1500) != null || c.SaveFiles > 0; return c; } diff --git a/WorldState.cs b/WorldState.cs index 9a130e4..88f8c95 100644 --- a/WorldState.cs +++ b/WorldState.cs @@ -49,6 +49,21 @@ public sealed class WorldState Dirty = false; } + /// + /// Ein neuer Spielstand vom Spiel selbst ist da: alles, was bisher nachgespielt werden musste (Aktionen, Positionen, + /// Fahrzeugwerte, Türen, Geld), steckt jetzt darin. Behalten werden nur die letzten Spielerpositionen und die von + /// Admins gespawnten Items. + /// + public void ResetForNewSave() + { + Objects.Clear(); + Events.Clear(); + MoneyDelta = 0f; + Doors.Clear(); + Vars.Clear(); + Dirty = true; + } + public void Reset() { Spawns.Clear();