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 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 11:15:45 +02:00
co-authored by Claude Opus 5.5
parent 57e1d3d3a8
commit 2deb6d2d1e
6 changed files with 222 additions and 8 deletions
+150 -5
View File
@@ -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<string, byte[]> Files = new();
}
SaveUpload saveUpload;
int nextSaveRequestId = 1;
double nextSaveSync;
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
const long MaxUploadBytes = 32L * 1024 * 1024;
const int KeepSaveBackups = 5;
/// <summary>So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten.</summary>
public double SaveUploadTimeoutSeconds { get; set; } = 15;
/// <summary>Ohne Master-Server (nur für automatische Tests): keine Heartbeats, keine Serverliste.</summary>
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
}
/// <summary>Chatnachricht vom Server an alle (Absender-ID 0 = "Server").</summary>
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();
}
/// <summary>
/// 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)
{
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();
}
/// <summary>Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
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 { }
}
/// <summary>
/// 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.