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.
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,6 +21,11 @@ public sealed class ServerConfig
|
||||
public bool RequireSteam { get; set; } = true;
|
||||
/// <summary>Weltzustand so oft auf die Festplatte schreiben.</summary>
|
||||
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.
|
||||
/// </summary>
|
||||
public int SaveSyncMinutes { get; set; } = 5;
|
||||
/// <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>
|
||||
|
||||
+42
-2
@@ -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<byte>());
|
||||
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<byte>());
|
||||
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<string, byte[]> Files = new();
|
||||
|
||||
public static async Task<TestClient> 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;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ public sealed class WorldState
|
||||
Dirty = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void ResetForNewSave()
|
||||
{
|
||||
Objects.Clear();
|
||||
Events.Clear();
|
||||
MoneyDelta = 0f;
|
||||
Doors.Clear();
|
||||
Vars.Clear();
|
||||
Dirty = true;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Spawns.Clear();
|
||||
|
||||
Reference in New Issue
Block a user