From f23671704dace1013bc8ae87d059b720939d6276 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sun, 27 Sep 2026 17:48:28 +0200 Subject: [PATCH] reset-world restores the original save (reset point in data/save_original), set-reset-point; per-player needs stored and returned; tests Co-Authored-By: Claude Opus 5.5 --- GameServer.cs | 82 +++++++++++++++++++++++++++++++++++++++++++++--- Program.cs | 4 ++- Protocol.cs | 5 +-- README.md | 2 ++ Tests/Program.cs | 40 +++++++++++++++++++++++ WorldState.cs | 37 ++++++++++++++++++++++ 6 files changed, 162 insertions(+), 8 deletions(-) diff --git a/GameServer.cs b/GameServer.cs index 4faf01f..6ac170e 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -93,6 +93,7 @@ public sealed class GameServer this.config = config; this.configPath = configPath; savePath = Path.Combine(dataDir, "save"); + originalPath = Path.Combine(dataDir, "save_original"); worldPath = Path.Combine(dataDir, "world.json"); master = new MasterClient(MasterServer.Url); steam = new SteamVerifier(config.SteamWebApiKey); @@ -137,6 +138,7 @@ public sealed class GameServer public void Run() { + EnsureResetPoint(); listener = new TcpListener(IPAddress.Any, config.Port); listener.Start(); Log($"Listening on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)"); @@ -347,6 +349,11 @@ public sealed class GameServer FinishSaveUpload(c, m.Reader()); return; + case MsgType.PlayerNeeds: + // Nur speichern, nicht weiterleiten - die Bedürfnisse gehören jedem selbst + if (c.State == ClientState.Joined && DateTime.UtcNow >= c.TrackPositionFrom) world.ApplyNeeds(c.SteamId, m.Payload); + 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; } @@ -384,6 +391,11 @@ public sealed class GameServer return; } else if (action == 6) result = BackupNow(c.Name); + else if (action == 8) + { + result = SetResetPoint() ? "The current save game is now the reset point" : "No save game to use as reset point"; + if (result.StartsWith("The")) Log($"{c.Name} set the current save game as the reset point"); + } else if (!clients.TryGetValue(targetId, out var target) || target.State != ClientState.Joined) result = "Player not found"; else if (action == 3) { @@ -670,14 +682,66 @@ public sealed class GameServer if (count > 0) Log($"{count} player(s) will reconnect"); } - /// Weltzustand zurücksetzen (Konsole oder Admin im Spiel): alle verbinden neu und laden den reinen Spielstand. + // ---------------------------------------------------------------- Reset-Punkt + // Der Spielstand, so wie er dem Server gegeben wurde (Start, --import-save, reload-save). Das automatische + // Hochladen alle paar Minuten überschreibt nur data/save - "reset-world" holt diesen Stand zurück. + string originalPath; + + /// Beim Start: gibt es noch keinen Reset-Punkt, ist es der aktuelle Spielstand. + void EnsureResetPoint() + { + if (File.Exists(Path.Combine(originalPath, "defaultES2File.txt"))) return; + if (SetResetPoint()) Log("Remembered the current save game as the reset point (reset-world goes back to it)"); + } + + /// Aktuellen Spielstand als Reset-Punkt merken. + bool SetResetPoint() + { + if (!File.Exists(Path.Combine(savePath, "defaultES2File.txt"))) return false; + try + { + if (Directory.Exists(originalPath)) Directory.Delete(originalPath, true); + Directory.CreateDirectory(originalPath); + foreach (string file in Directory.GetFiles(savePath)) + if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(originalPath, Path.GetFileName(file)), true); + return true; + } + catch (Exception e) { Log("Could not remember the reset point: " + e.Message); return false; } + } + + /// + /// Welt komplett zurücksetzen (Konsole oder Admin im Spiel): Spielstand zurück auf den Reset-Punkt, alle + /// gespeicherten Änderungen (Positionen, gekaufte Items, Türen, Fahrzeugwerte ...) weg, alle verbinden neu. + /// Der Stand davor landet als Backup in save_backups. + /// string ResetWorld(string by) { + bool restored = false; + if (File.Exists(Path.Combine(originalPath, "defaultES2File.txt"))) + { + try + { + BackupSave(); + Directory.CreateDirectory(savePath); + foreach (string name in SaveFiles) + { + string from = Path.Combine(originalPath, name), to = Path.Combine(savePath, name); + if (File.Exists(from)) File.Copy(from, to, true); + else if (File.Exists(to)) File.Delete(to); // gab es im Original nicht + } + restored = LoadSave(); + } + catch (Exception e) { Log("Could not restore the reset point: " + e.Message); } + } + // Laufendes Hochladen eines Spielstands (alter Stand) verwerfen + saveUpload = null; + waitingForSave.Clear(); world.Reset(); SaveWorld(); - Log($"World state reset by {by} - new players get the plain save game again."); + Log(restored ? $"World reset by {by} - back to the original save game (previous state backed up)." + : $"World state reset by {by} - new players get the plain save game again."); ReconnectAll(by == "Console" ? "The world is being reset - reconnecting..." : $"{by} reset the world - reconnecting..."); - return "World reset"; + return restored ? "World reset to the original save game" : "World reset"; } /// Aktuellen Spielstand jetzt als Backup sichern. @@ -780,7 +844,7 @@ public sealed class GameServer switch (args[0].ToLowerInvariant()) { case "help": - Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | say | op | deop | admins | save | reset-world | reload-save | stop"); + Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | say | op | deop | admins | save | reset-world | reload-save | stop"); break; case "say": if (args.Length < 2) { Log("say "); break; } @@ -875,7 +939,15 @@ public sealed class GameServer Log(RestoreSave(args.Length > 1 ? args[1].Trim() : "", "Console")); break; case "reload-save": - if (LoadSave()) ReconnectAll("The server loaded a new save game - reconnecting..."); + if (LoadSave()) + { + // Neuer Spielstand von Hand hineinkopiert: ab jetzt ist das der Reset-Punkt + SetResetPoint(); + ReconnectAll("The server loaded a new save game - reconnecting..."); + } + break; + case "set-reset-point": + Log(SetResetPoint() ? "The current save game is now the reset point" : "No save game to use as reset point"); break; case "stop": case "quit": diff --git a/Program.cs b/Program.cs index 74a0726..bb47a3b 100644 --- a/Program.cs +++ b/Program.cs @@ -87,9 +87,11 @@ if (Array.IndexOf(args, "--import-save") >= 0) copied++; } GameServer.Log($"Imported {copied} save files from {source}"); - // Neuer Spielstand = alte Änderungen passen nicht mehr dazu + // Neuer Spielstand = alte Änderungen passen nicht mehr dazu, und er ist der neue Reset-Punkt string world = Path.Combine(dataDir, "world.json"); if (File.Exists(world)) File.Delete(world); + string original = Path.Combine(dataDir, "save_original"); + if (Directory.Exists(original)) Directory.Delete(original, true); } // Lizenzschlüssel beim Keymaster prüfen diff --git a/Protocol.cs b/Protocol.cs index 3fe5470..1ef74fc 100644 --- a/Protocol.cs +++ b/Protocol.cs @@ -30,7 +30,7 @@ public enum MsgType : byte AdminStatus = 23, // Server -> Client: bool, ist Admin AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin] - AdminAction = 26, // Client -> Server: byte (1 kick, 2 ban, 3 bring, 4 unban, 5 reset world, 6 backup, 7 restore backup), int Spieler-ID, string Grund/Name + AdminAction = 26, // Client -> Server: byte (1 kick, 2 ban, 3 bring, 4 unban, 5 reset world, 6 backup, 7 restore backup, 8 set reset point), int Spieler-ID, string Grund/Name AdminResult = 27, // Server -> Client: string Meldung Chat = 28, // Chatnachricht (string) VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert] @@ -47,7 +47,8 @@ public enum MsgType : byte TrafficStates = 40, // Verkehr, nur von der Welt-Autorität: int n, je [int Schlüssel, 3 float Pos, 4 float Rot, 3 float Geschw.] WorldVars = 41, // Jobs/Laden: int n, je [int Schlüssel, byte Typ, Wert] HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, float Scharnier-Winkel] - VehicleControls = 43 // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand] + VehicleControls = 43, // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand] + PlayerNeeds = 44 // Client -> Server: eigene Bedürfnisse (Hunger, Durst ...): int n, je [string Name, float Wert] } public readonly record struct Message(MsgType Type, byte[] Payload) diff --git a/README.md b/README.md index 76cf6d8..99bb4b3 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvari | `reload-save` | Spielstand-Dateien neu einlesen – verbundene Spieler verbinden sich automatisch neu | | `backups`, `restore-save ` | gesicherte Spielstände anzeigen / einen davon zurückholen (z.B. wenn der aktuelle kaputt ist) – alle verbinden sich neu | | `backup` | aktuellen Spielstand jetzt sichern | +| `reset-world` | ganze Welt zurück auf den Reset-Punkt (den Spielstand, wie er dem Server gegeben wurde: Start, `--import-save`, `reload-save`) – alle verbinden neu | +| `set-reset-point` | aktuellen Spielstand als neuen Reset-Punkt nehmen | | `stop` | speichern und beenden | Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban, diff --git a/Tests/Program.cs b/Tests/Program.cs index f0bd457..78c5c60 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -145,6 +145,29 @@ try Check(lateSnap.Destroyed.Count <= 21, "Mass 'consumed' reports (e.g. scene unload) are capped: " + lateSnap.Destroyed.Count + " stored"); late.Close(); + Console.WriteLine("== Own needs (hunger, thirst ...) are stored per player"); + var hungry = await TestClient.Join("Hungry", 0, null); + hungry.Send(MsgType.Ready, Array.Empty()); + await hungry.WaitFor(MsgType.WorldSnapshot); + server.EnqueueCommand("save"); // (Zeitfenster TrackPositionFrom abwarten) + await Task.Delay(8500); + hungry.Send(MsgType.PlayerNeeds, Wire.Build(w => { w.Write(2); w.Write("PlayerHunger"); w.Write(77f); w.Write("PlayerThirst"); w.Write(12.5f); })); + await Task.Delay(300); + hungry.Close(); + await Task.Delay(500); + var hungry2 = await TestClient.Join("Hungry", 0, null); + hungry2.Send(MsgType.Ready, Array.Empty()); + var needsSnap = Snapshot.Parse(await hungry2.WaitFor(MsgType.WorldSnapshot)); + Check(needsSnap.Ok && needsSnap.Needs.TryGetValue("PlayerHunger", out float hunger) && Math.Abs(hunger - 77f) < 0.01f, + "Rejoining player gets their own needs back: " + string.Join(", ", needsSnap.Needs.Select(kv => kv.Key + "=" + kv.Value))); + var otherSnapClient = await TestClient.Join("NotHungry", 0, null); + otherSnapClient.Send(MsgType.Ready, Array.Empty()); + var otherSnap = Snapshot.Parse(await otherSnapClient.WaitFor(MsgType.WorldSnapshot)); + Check(otherSnap.Ok && otherSnap.Needs.Count == 0, "Other players do not get someone else's needs"); + hungry2.Close(); + otherSnapClient.Close(); + await Task.Delay(300); + Console.WriteLine("== Last position (spawn)"); var pos1 = await TestClient.Join("Walker", 0, null); pos1.Send(MsgType.Player, PlayerPos(-15.6f, -0.3f, 7f)); // Startpunkt des Spielstands (Toilette) - vor Ready @@ -253,6 +276,17 @@ try var restoreMsg = await newcomer.WaitFor(MsgType.Reconnect); Check(restoreMsg != null, "Restoring a backup makes everyone reconnect"); Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).EndsWith("CURRENT-SAVE"), "The backup's files are back in the active save"); + + Console.WriteLine("== reset-world goes back to the ORIGINAL save (not the auto-uploaded one)"); + var r2 = await TestClient.Join("Resetter2", 0, null); + server.EnqueueCommand("reset-world"); + Check(await r2.WaitFor(MsgType.Reconnect) != null, "Players reconnect after the reset"); + Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "test defaultES2File.txt", + "The active save is the original one again: " + File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).Length + " chars"); + var afterReset = await TestClient.Join("AfterReset", 0, null); + Check(afterReset.Files.TryGetValue("defaultES2File.txt", out var orig) && System.Text.Encoding.UTF8.GetString(orig) == "test defaultES2File.txt", + "Joining players download the original save"); + afterReset.Close(); inGame.Close(); newcomer.Close(); } @@ -292,6 +326,7 @@ sealed class Snapshot public int Spawns = -1; public int Appeared = -1; public List Destroyed = new(); + public Dictionary Needs = new(); public int Vars = -1; public static Snapshot Parse(byte[] data) @@ -317,6 +352,11 @@ sealed class Snapshot for (int i = 0; i < s.Appeared; i++) r.ReadBytes(r.ReadInt32()); int destroyed = r.ReadInt32(); for (int i = 0; i < destroyed; i++) s.Destroyed.Add(r.ReadInt32()); + if (r.ReadBoolean()) + { + int needs = r.ReadInt32(); + for (int i = 0; i < needs; i++) s.Needs[r.ReadString()] = r.ReadSingle(); + } s.Ok = r.BaseStream.Position == r.BaseStream.Length; s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars"; } diff --git a/WorldState.cs b/WorldState.cs index 9771207..caa22a2 100644 --- a/WorldState.cs +++ b/WorldState.cs @@ -19,6 +19,8 @@ public sealed class WorldState public Dictionary Objects { get; set; } = new(); /// FsmEvent-Payloads (Base64) in der Reihenfolge, in der sie passiert sind. public List Events { get; set; } = new(); + /// SteamID64 -> eigene Bedürfnisse (Hunger, Durst, Stress ...), Name -> Wert. + public Dictionary> PlayerNeeds { get; set; } = new(); /// SteamID64 -> letzte Position. public Dictionary PlayerPositions { get; set; } = new(); /// Summe aller Geldänderungen seit dem Ausgangs-Spielstand (gemeinsame Kasse). @@ -74,6 +76,7 @@ public sealed class WorldState public void Reset() { + PlayerNeeds.Clear(); Spawns.Clear(); Appeared.Clear(); Destroyed.Clear(); @@ -215,6 +218,30 @@ public sealed class WorldState Dirty = true; } + /// PlayerNeeds: int n, je [string Name, float Wert]. Werte werden begrenzt, Unsinn verworfen. + public void ApplyNeeds(string steamId, byte[] payload) + { + if (string.IsNullOrEmpty(steamId) || payload.Length > 1024) return; + try + { + using var r = new BinaryReader(new MemoryStream(payload)); + int count = r.ReadInt32(); + if (count < 0 || count > 16) return; + var needs = new Dictionary(); + for (int i = 0; i < count; i++) + { + string name = r.ReadString(); + float value = r.ReadSingle(); + if (name.Length == 0 || name.Length > 40 || !float.IsFinite(value)) continue; + needs[name] = Math.Clamp(value, -1000f, 1000f); + } + if (needs.Count == 0) return; + PlayerNeeds[steamId] = needs; + Dirty = true; + } + catch (EndOfStreamException) { } + } + // ---------------------------------------------------------------- Snapshot für neue Spieler /// @@ -280,6 +307,16 @@ public sealed class WorldState } w.Write(Destroyed.Count); foreach (int id in Destroyed) w.Write(id); + + // Eigene Bedürfnisse des Beitretenden: bool vorhanden [int n, je string Name, float Wert] + bool hasNeeds = steamId != null && PlayerNeeds.TryGetValue(steamId, out var own) && own.Count > 0; + w.Write(hasNeeds); + if (hasNeeds) + { + var needs = PlayerNeeds[steamId]; + w.Write(needs.Count); + foreach (var kv in needs) { w.Write(kv.Key); w.Write(kv.Value); } + } }); } }