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:
+150
-5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user