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); }
+ }
});
}
}