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 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 17:48:28 +02:00
co-authored by Claude Opus 5.5
parent bd45a6e70c
commit f23671704d
6 changed files with 162 additions and 8 deletions
+77 -5
View File
@@ -93,6 +93,7 @@ public sealed class GameServer
this.config = config; this.config = config;
this.configPath = configPath; this.configPath = configPath;
savePath = Path.Combine(dataDir, "save"); savePath = Path.Combine(dataDir, "save");
originalPath = Path.Combine(dataDir, "save_original");
worldPath = Path.Combine(dataDir, "world.json"); worldPath = Path.Combine(dataDir, "world.json");
master = new MasterClient(MasterServer.Url); master = new MasterClient(MasterServer.Url);
steam = new SteamVerifier(config.SteamWebApiKey); steam = new SteamVerifier(config.SteamWebApiKey);
@@ -137,6 +138,7 @@ public sealed class GameServer
public void Run() public void Run()
{ {
EnsureResetPoint();
listener = new TcpListener(IPAddress.Any, config.Port); listener = new TcpListener(IPAddress.Any, config.Port);
listener.Start(); listener.Start();
Log($"Listening on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)"); Log($"Listening on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)");
@@ -347,6 +349,11 @@ public sealed class GameServer
FinishSaveUpload(c, m.Reader()); FinishSaveUpload(c, m.Reader());
return; 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: case MsgType.AdminListRequest:
if (c.State != ClientState.Joined) return; 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; } 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; return;
} }
else if (action == 6) result = BackupNow(c.Name); 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 (!clients.TryGetValue(targetId, out var target) || target.State != ClientState.Joined) result = "Player not found";
else if (action == 3) else if (action == 3)
{ {
@@ -670,14 +682,66 @@ public sealed class GameServer
if (count > 0) Log($"{count} player(s) will reconnect"); if (count > 0) Log($"{count} player(s) will reconnect");
} }
/// <summary>Weltzustand zurücksetzen (Konsole oder Admin im Spiel): alle verbinden neu und laden den reinen Spielstand.</summary> // ---------------------------------------------------------------- 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;
/// <summary>Beim Start: gibt es noch keinen Reset-Punkt, ist es der aktuelle Spielstand.</summary>
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)");
}
/// <summary>Aktuellen Spielstand als Reset-Punkt merken.</summary>
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; }
}
/// <summary>
/// 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.
/// </summary>
string ResetWorld(string by) 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(); world.Reset();
SaveWorld(); 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..."); 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";
} }
/// <summary>Aktuellen Spielstand jetzt als Backup sichern.</summary> /// <summary>Aktuellen Spielstand jetzt als Backup sichern.</summary>
@@ -780,7 +844,7 @@ public sealed class GameServer
switch (args[0].ToLowerInvariant()) switch (args[0].ToLowerInvariant())
{ {
case "help": case "help":
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop"); Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
break; break;
case "say": case "say":
if (args.Length < 2) { Log("say <text>"); break; } if (args.Length < 2) { Log("say <text>"); break; }
@@ -875,7 +939,15 @@ public sealed class GameServer
Log(RestoreSave(args.Length > 1 ? args[1].Trim() : "", "Console")); Log(RestoreSave(args.Length > 1 ? args[1].Trim() : "", "Console"));
break; break;
case "reload-save": 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; break;
case "stop": case "stop":
case "quit": case "quit":
+3 -1
View File
@@ -87,9 +87,11 @@ if (Array.IndexOf(args, "--import-save") >= 0)
copied++; copied++;
} }
GameServer.Log($"Imported {copied} save files from {source}"); 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"); string world = Path.Combine(dataDir, "world.json");
if (File.Exists(world)) File.Delete(world); 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 // Lizenzschlüssel beim Keymaster prüfen
+3 -2
View File
@@ -30,7 +30,7 @@ public enum MsgType : byte
AdminStatus = 23, // Server -> Client: bool, ist Admin AdminStatus = 23, // Server -> Client: bool, ist Admin
AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin] 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 AdminResult = 27, // Server -> Client: string Meldung
Chat = 28, // Chatnachricht (string) Chat = 28, // Chatnachricht (string)
VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert] 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.] 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] 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] 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) public readonly record struct Message(MsgType Type, byte[] Payload)
+2
View File
@@ -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 | | `reload-save` | Spielstand-Dateien neu einlesen – verbundene Spieler verbinden sich automatisch neu |
| `backups`, `restore-save <nr>` | gesicherte Spielstände anzeigen / einen davon zurückholen (z.B. wenn der aktuelle kaputt ist) – alle verbinden sich neu | | `backups`, `restore-save <nr>` | gesicherte Spielstände anzeigen / einen davon zurückholen (z.B. wenn der aktuelle kaputt ist) – alle verbinden sich neu |
| `backup` | aktuellen Spielstand jetzt sichern | | `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 | | `stop` | speichern und beenden |
Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban, Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban,
+40
View File
@@ -145,6 +145,29 @@ try
Check(lateSnap.Destroyed.Count <= 21, "Mass 'consumed' reports (e.g. scene unload) are capped: " + lateSnap.Destroyed.Count + " stored"); Check(lateSnap.Destroyed.Count <= 21, "Mass 'consumed' reports (e.g. scene unload) are capped: " + lateSnap.Destroyed.Count + " stored");
late.Close(); 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<byte>());
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<byte>());
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<byte>());
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)"); Console.WriteLine("== Last position (spawn)");
var pos1 = await TestClient.Join("Walker", 0, null); var pos1 = await TestClient.Join("Walker", 0, null);
pos1.Send(MsgType.Player, PlayerPos(-15.6f, -0.3f, 7f)); // Startpunkt des Spielstands (Toilette) - vor Ready 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); var restoreMsg = await newcomer.WaitFor(MsgType.Reconnect);
Check(restoreMsg != null, "Restoring a backup makes everyone 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"); 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(); inGame.Close();
newcomer.Close(); newcomer.Close();
} }
@@ -292,6 +326,7 @@ sealed class Snapshot
public int Spawns = -1; public int Spawns = -1;
public int Appeared = -1; public int Appeared = -1;
public List<int> Destroyed = new(); public List<int> Destroyed = new();
public Dictionary<string, float> Needs = new();
public int Vars = -1; public int Vars = -1;
public static Snapshot Parse(byte[] data) 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()); for (int i = 0; i < s.Appeared; i++) r.ReadBytes(r.ReadInt32());
int destroyed = r.ReadInt32(); int destroyed = r.ReadInt32();
for (int i = 0; i < destroyed; i++) s.Destroyed.Add(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.Ok = r.BaseStream.Position == r.BaseStream.Length;
s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars"; s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars";
} }
+37
View File
@@ -19,6 +19,8 @@ public sealed class WorldState
public Dictionary<int, ObjectPose> Objects { get; set; } = new(); public Dictionary<int, ObjectPose> Objects { get; set; } = new();
/// <summary>FsmEvent-Payloads (Base64) in der Reihenfolge, in der sie passiert sind.</summary> /// <summary>FsmEvent-Payloads (Base64) in der Reihenfolge, in der sie passiert sind.</summary>
public List<string> Events { get; set; } = new(); public List<string> Events { get; set; } = new();
/// <summary>SteamID64 -> eigene Bedürfnisse (Hunger, Durst, Stress ...), Name -> Wert.</summary>
public Dictionary<string, Dictionary<string, float>> PlayerNeeds { get; set; } = new();
/// <summary>SteamID64 -> letzte Position.</summary> /// <summary>SteamID64 -> letzte Position.</summary>
public Dictionary<string, float[]> PlayerPositions { get; set; } = new(); public Dictionary<string, float[]> PlayerPositions { get; set; } = new();
/// <summary>Summe aller Geldänderungen seit dem Ausgangs-Spielstand (gemeinsame Kasse).</summary> /// <summary>Summe aller Geldänderungen seit dem Ausgangs-Spielstand (gemeinsame Kasse).</summary>
@@ -74,6 +76,7 @@ public sealed class WorldState
public void Reset() public void Reset()
{ {
PlayerNeeds.Clear();
Spawns.Clear(); Spawns.Clear();
Appeared.Clear(); Appeared.Clear();
Destroyed.Clear(); Destroyed.Clear();
@@ -215,6 +218,30 @@ public sealed class WorldState
Dirty = true; Dirty = true;
} }
/// <summary>PlayerNeeds: int n, je [string Name, float Wert]. Werte werden begrenzt, Unsinn verworfen.</summary>
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<string, float>();
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 // ---------------------------------------------------------------- Snapshot für neue Spieler
/// <summary> /// <summary>
@@ -280,6 +307,16 @@ public sealed class WorldState
} }
w.Write(Destroyed.Count); w.Write(Destroyed.Count);
foreach (int id in Destroyed) w.Write(id); 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); }
}
}); });
} }
} }