using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using System.Net.Sockets; namespace MSCO.Server; /// /// Dedizierter Server: Verteiler zwischen den Spielern (wie der Host bei P2P, aber ohne eigenes Spiel), /// hält den Spielstand und den Weltzustand und schickt beides an neue Spieler. /// Die Physik simuliert weiterhin der Spieler, der ein Objekt gerade besitzt. /// public sealed class GameServer { public const string ModVersion = "2.8"; // muss mit MSCO.ModVersion übereinstimmen public const int ServerId = 0; // Absender-ID des Servers; Spieler bekommen 1, 2, ... static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }; const double HeartbeatSeconds = 30; enum ClientState { New, Verifying, Joined } sealed class Client { public int Id; public Connection Conn; public ClientState State; public string Name = "?"; public string SteamId; /// SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte. public bool SteamVerified; public bool IsAdmin; public DateTime JoinedAt; public DateTime DestroyWindowStart; public int DestroyCount; /// /// Ab wann die Position des Spielers gespeichert wird: erst nachdem er seinen Weltzustand (mit seiner letzten /// Position) bekommen und sich dorthin gesetzt hat - sonst überschreibt der Startpunkt des Spielstands /// (Toilette) die gespeicherte Position, bevor er sie zurückbekommt. /// public DateTime TrackPositionFrom = DateTime.MaxValue; public Task Verification; } readonly ServerConfig config; readonly string configPath; readonly string ownerName; // Besitzer des Lizenzschlüssels (Server-Profil im Browser) readonly string savePath; readonly string worldPath; readonly MasterClient master; readonly SteamVerifier steam; readonly WorldState world; readonly Dictionary clients = new(); readonly ConcurrentQueue commands = new(); readonly ConcurrentQueue actions = new(); // vom Web-Panel: laufen in der Server-Schleife readonly Stopwatch clock = Stopwatch.StartNew(); TcpListener listener; List> saveFiles = new(); int nextId = 1; byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand double nextAutosave, nextHeartbeat; Task heartbeat; bool? lastReachable; 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 Files = new(); } SaveUpload saveUpload; int nextSaveRequestId = 1; double nextSaveSync; readonly List waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten const long MaxUploadBytes = 32L * 1024 * 1024; const int KeepSaveBackups = 5; /// So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten. public double SaveUploadTimeoutSeconds { get; set; } = 15; const int MaxDestroyPerMinute = 20; /// Ohne Master-Server (nur für automatische Tests): keine Heartbeats, keine Serverliste. readonly bool offline; public GameServer(ServerConfig config, string dataDir, string configPath, string ownerName, bool offline = false) { this.offline = offline; this.ownerName = ownerName; 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); world = WorldState.Load(worldPath); } public void EnqueueCommand(string line) => commands.Enqueue(line); int JoinedCount => clients.Values.Count(c => c.State == ClientState.Joined && !c.Conn.IsClosing); // gerade getrennte zählen nicht mehr // ---------------------------------------------------------------- Start / Schleife public bool LoadSave() { // Erst alles lesen, dann übernehmen: schlägt "reload-save" fehl, bleibt der bisherige Spielstand aktiv var loaded = new List>(); try { Directory.CreateDirectory(savePath); foreach (string name in SaveFiles) { string file = Path.Combine(savePath, name); if (File.Exists(file)) loaded.Add(new(name, File.ReadAllBytes(file))); } } catch (Exception e) when (e is UnauthorizedAccessException or IOException) { Log("Cannot read the save game: " + e.Message); Log(PermissionHint()); return false; } if (loaded.All(f => f.Key != "defaultES2File.txt")) { Log("No save game found in " + savePath); Log("Copy your My Summer Car save files there, or start the server with --import-save."); return false; } saveFiles = loaded; Log("Loaded save game (" + saveFiles.Count + " files, " + saveFiles.Sum(f => f.Value.Length) / 1024 + " KB)"); return true; } 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)"); if (!config.RequireSteam) Log("Non-Steam server: players without Steam can join (RequireSteam = false)"); else Log(steam.Enabled ? "Steam required - tickets are verified with the Steam Web API" : "Steam required (no Web API key set, tickets are not verified online)"); Log($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions"); if (!steam.Enabled && config.Admins.Count > 0) Log("WARNING: Admins are disabled - without SteamWebApiKey anyone could pretend to be an admin. " + "Get a free key at https://steamcommunity.com/dev/apikey and put it into server.json (\"SteamWebApiKey\")."); Log("Type 'help' for commands."); nextHeartbeat = 0; nextAutosave = config.AutosaveSeconds; while (running) { AcceptClients(); PollClients(); RunCommands(); Timers(); WaitForTraffic(); } foreach (var c in clients.Values) { // Meist ein Neustart (Update, docker compose up): die Spieler verbinden sich automatisch neu, sobald der // Server wieder da ist. Bleibt er aus, bekommen sie nach ein paar Versuchen einen Hinweis im Hauptmenü. if (c.State == ClientState.Joined) c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write("The server is restarting - reconnecting..."))); c.Conn.CloseAfterSend("Server shutting down"); } // Kurz warten, bis die Spieler die Meldung gelesen haben (sauber trennen statt hart abbrechen) var waitUntil = DateTime.UtcNow.AddSeconds(1.5); while (DateTime.UtcNow < waitUntil && clients.Values.Any(c => !c.Conn.IsClosed)) { foreach (var c in clients.Values) c.Conn.Poll(); Thread.Sleep(20); } foreach (var c in clients.Values) c.Conn.Close("Server shutting down"); listener.Stop(); SaveWorld(); if (!offline) { master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste Log("Removed from the server list."); } Log("Server stopped."); } /// Web-Panel (gesetzt vom Programm). Teilt es sich den Spiel-Port, werden Browser hier erkannt und weitergereicht. public AdminPanel Panel { get; set; } readonly ConcurrentQueue sniffedGameClients = new(); void AcceptClients() { while (listener.Pending()) { var tcp = listener.AcceptTcpClient(); if (Panel != null && Panel.SharesGamePort) _ = SniffAsync(tcp); else AddClient(tcp); } while (sniffedGameClients.TryDequeue(out var tcp)) AddClient(tcp); } void AddClient(TcpClient tcp) { var c = new Client { Id = nextId++, Conn = new Connection(tcp), State = ClientState.New }; clients[c.Id] = c; } /// /// Gleicher Port für Spiel und Web-Panel: in die ersten Bytes schauen (ohne sie zu verbrauchen). Ein Browser /// schickt "GET /", "POST" ... - das Spiel beginnt mit einer kleinen Längenangabe (Bytes 2-4 sind 0). /// Beide Seiten reden zuerst, der Server muss also nichts senden, bevor er es weiß. /// async Task SniffAsync(TcpClient tcp) { var buffer = new byte[4]; int n; try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); while (true) { n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token); if (n == 0) { tcp.Close(); return; } if (n >= 4 || buffer[0] < 'A' || buffer[0] > 'Z') break; // kein Buchstabe = sicher das Spiel await Task.Delay(15, timeout.Token); } } catch { try { tcp.Close(); } catch { } return; } if (AdminPanel.LooksLikeHttp(buffer, n)) await Panel.RelayAsync(tcp); else sniffedGameClients.Enqueue(tcp); } void PollClients() { List closed = null; foreach (var c in clients.Values.ToList()) { foreach (var m in c.Conn.Poll()) { try { Handle(c, m); } catch (Exception e) { c.Conn.Close("Bad message: " + e.Message); } } if (c.State == ClientState.Verifying && c.Verification.IsCompleted) FinishJoin(c); // Abgebrochene Verbindungen (Absturz, Netz weg) melden sich nicht ab - ohne Lebenszeichen raus, // sonst zählen sie ewig als Spieler mit if (!c.Conn.IsClosed && DateTime.UtcNow - c.Conn.LastReceiveUtc > ClientTimeout) c.Conn.Close("Timed out"); if (c.Conn.IsClosed) (closed ??= new()).Add(c); } if (closed == null) return; foreach (var c in closed) { clients.Remove(c.Id); if (c.State != ClientState.Joined) continue; Log($"{c.Name} left ({c.Conn.CloseReason})"); Broadcast(MsgType.PlayerLeft, Wire.Build(w => w.Write(c.Id))); HeartbeatSoon(); if (world.Dirty) SaveWorld(); // letzte Position gleich sichern, nicht erst beim nächsten Autosave } } /// Spielerzahl hat sich geändert: gleich beim Master melden statt erst in 30 s. void HeartbeatSoon() => nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 1); void Timers() { double now = clock.Elapsed.TotalSeconds; if (now >= nextAutosave) { nextAutosave = now + config.AutosaveSeconds; if (world.Dirty) SaveWorld(); } // Regelmäßig den aktuellen Spielstand vom Spiel holen if (config.SaveSyncMinutes > 0 && !GameStopped && 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; heartbeat = null; if (result.Status == 401 || result.Status == 403) { Log("License key was rejected by the master server - shutting down."); running = false; } else if (result.Status == 0) Log("Master server not reachable - server is not listed right now."); else ReportReachability(result); } if (!offline && !GameStopped && now >= nextHeartbeat && heartbeat == null) { nextHeartbeat = now + HeartbeatSeconds; heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion); } } /// Erreichbarkeit laut Master nur bei Änderung ausgeben. void ReportReachability(MasterClient.HeartbeatResult r) { // Prüfung läuft noch: in ein paar Sekunden nachfragen statt erst in 30 s if (r.Reachable == null) nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 6); if (r.Reachable == null || r.Reachable == lastReachable) return; lastReachable = r.Reachable; if (r.Reachable == true) Log($"Reachable from the internet at {r.Address}:{r.Port}" + (r.Listed ? " - listed in the server browser" : " (not public)")); else Log($"NOT reachable from the internet at {r.Address}:{r.Port} - not listed! Forward TCP port {r.Port} " + "in your router/firewall, or set PublicAddress / MSCO_PUBLIC_ADDRESS if the address is wrong."); } // ---------------------------------------------------------------- Nachrichten static bool IsGameMessage(MsgType t) => t is MsgType.Player or MsgType.ObjStates or MsgType.ObjOwn or MsgType.ObjRelease or MsgType.FsmEvent or MsgType.VehicleFx or MsgType.WorldTime or MsgType.MoneyDelta or MsgType.DoorState or MsgType.Chat or MsgType.VarSync or MsgType.SpawnItem or MsgType.ItemAppeared or MsgType.ObjDestroy or MsgType.VehicleDamage or MsgType.TrafficStates or MsgType.WorldVars or MsgType.HingeStates or MsgType.VehicleControls; /// Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients). int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min(); void Handle(Client c, Message m) { switch (m.Type) { case MsgType.Info: // Server-Browser: Infos + Ping, danach Verbindung schließen c.Conn.Send(MsgType.InfoReply, Wire.Build(w => { w.Write(config.Name); w.Write(config.Description ?? ""); w.Write(JoinedCount); w.Write(config.MaxPlayers); w.Write(ModVersion); // Erweiterung für das Server-Profil im Browser (ältere Mods lesen hier nicht weiter) w.Write(config.RequireSteam); w.Write(ownerName ?? ""); w.Write((int)clock.Elapsed.TotalSeconds); var names = clients.Values.Where(x => x.State == ClientState.Joined).Select(x => x.Name).ToList(); w.Write(names.Count); foreach (string n in names) w.Write(n); })); c.Conn.CloseAfterSend("Info query"); return; case MsgType.Hello: if (c.State == ClientState.New) BeginJoin(c, m.Reader()); return; case MsgType.Ready: if (c.State != ClientState.Joined) return; c.TrackPositionFrom = DateTime.UtcNow.AddSeconds(8); c.Conn.Send(MsgType.WorldSnapshot, world.BuildSnapshot(c.SteamId)); // Letzter Stand von Uhrzeit und Wetter, damit der Neue nicht bis zum nächsten Update warten muss if (lastWorldTime != null) c.Conn.Send(MsgType.WorldTime, Wire.WithSender(ServerId, lastWorldTime)); Log($"{c.Name} is in the game (sent {world.Objects.Count} objects, {world.Events.Count} actions)"); return; case MsgType.Ping: // Mit Zeitstempel: sofort zurückschicken, damit der Spieler seinen Ping sieht 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.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; } var joinedPlayers = clients.Values.Where(x => x.State == ClientState.Joined).ToList(); c.Conn.Send(MsgType.AdminList, Wire.Build(w => { w.Write(joinedPlayers.Count); foreach (var p in joinedPlayers) { w.Write(p.Id); w.Write(p.Name); w.Write(p.SteamId); w.Write(p.IsAdmin); } // Gebannte Spieler (für UNBAN) - am Ende angehängt, ältere Mods lesen hier nicht weiter w.Write(config.BannedSteamIds.Count); foreach (string id in config.BannedSteamIds) w.Write(id); // Spielstand-Backups (neueste zuerst) für den WORLD-Reiter var backups = SaveBackups(); w.Write(backups.Count); foreach (string b in backups) w.Write(Path.GetFileName(b)); })); return; case MsgType.AdminAction: { if (c.State != ClientState.Joined) return; var r = m.Reader(); byte action = r.ReadByte(); int targetId = r.ReadInt32(); string reason = r.ReadString(); string result; if (!c.IsAdmin) result = "You are not an admin on this server"; else if (action == 4) result = Unban(reason, c.Name); // "reason" enthält hier die gebannte ID else if (action == 5 || action == 7) { // Welt zurücksetzen / Backup wiederherstellen: alle (auch der Admin) verbinden danach neu c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write(action == 5 ? "Resetting the world..." : "Restoring " + reason + "..."))); string outcome = action == 5 ? ResetWorld(c.Name) : RestoreSave(reason, c.Name); if (!c.Conn.IsClosing && !c.Conn.IsClosed) c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write(outcome))); 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) { // Spieler zum Admin holen: Position kommt vom Admin if (r.BaseStream.Length - r.BaseStream.Position < 12) return; float x = r.ReadSingle(), y = r.ReadSingle(), z = r.ReadSingle(); if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z)) return; target.Conn.Send(MsgType.Teleport, Wire.Build(w => { w.Write(x); w.Write(y); w.Write(z); })); result = $"Brought {target.Name} to you"; Log($"{c.Name} teleported {target.Name} to themselves"); } else if (target.IsAdmin && target != c) result = "You cannot kick or ban another admin"; else result = KickOrBan(target, action == 2, reason, c.Name); c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write(result))); return; } } if (c.State != ClientState.Joined || !IsGameMessage(m.Type)) return; // Weltzustand mitschreiben switch (m.Type) { case MsgType.ObjStates: world.ApplyObjStates(m.Payload); break; case MsgType.ObjRelease: world.ApplyObjRelease(m.Payload); break; case MsgType.FsmEvent: world.AddFsmEvent(m.Payload); break; case MsgType.Player: if (DateTime.UtcNow >= c.TrackPositionFrom) world.ApplyPlayer(c.SteamId, m.Payload); break; case MsgType.MoneyDelta: world.ApplyMoney(m.Payload); break; case MsgType.DoorState: world.ApplyDoor(m.Payload); break; case MsgType.VarSync: world.ApplyVars(m.Payload); break; case MsgType.SpawnItem: if (!c.IsAdmin) return; // nur Admins dürfen Items spawnen world.AddSpawn(m.Payload); Log($"{c.Name} spawned {SpawnName(m.Payload)}"); break; case MsgType.ItemAppeared: if (m.Payload.Length > 256) return; world.AddAppeared(m.Payload); break; case MsgType.ObjDestroy: if (m.Payload.Length != 4) return; // Niemand isst/verbraucht 20 Dinge pro Minute - mehr ist ein Fehler (z.B. Szene entladen) oder Absicht if (DateTime.UtcNow - c.DestroyWindowStart > TimeSpan.FromMinutes(1)) { c.DestroyWindowStart = DateTime.UtcNow; c.DestroyCount = 0; } if (++c.DestroyCount > MaxDestroyPerMinute) return; world.AddDestroyed(BitConverter.ToInt32(m.Payload, 0)); break; case MsgType.Chat: if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten Log($"[chat] {c.Name}: {m.Reader().ReadString()}"); break; case MsgType.TrafficStates: if (c.Id != WorldAuthorityId) return; // nur einer simuliert den Verkehr break; case MsgType.WorldTime: if (c.Id != WorldAuthorityId) return; // nur einer gibt Uhrzeit/Wetter vor lastWorldTime = m.Payload; break; } // an alle anderen weiterleiten, mit Absender Broadcast(m.Type, Wire.WithSender(c.Id, m.Payload), c.Id); } void BeginJoin(Client c, BinaryReader r) { string version = r.ReadString(); c.Name = r.ReadString(); ulong steamId = 0; byte[] ticket = null; if (r.BaseStream.Position < r.BaseStream.Length) { steamId = r.ReadUInt64(); ticket = r.ReadBytes(r.ReadInt32()); } c.Name = string.IsNullOrWhiteSpace(c.Name) ? "Player" : c.Name.Trim(); if (c.Name.Length > 24) c.Name = c.Name[..24]; // Spieler ohne Steam (Non-Steam-Server) werden über ihren Namen wiedererkannt (Position, Sperren) c.SteamId = steamId != 0 ? steamId.ToString() : "name:" + c.Name.ToLowerInvariant(); // Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken if (version != ModVersion) Log($"{c.Name} uses MSCO {version} (server {ModVersion}) - syncing may not work correctly"); if (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; } if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; } if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } c.State = ClientState.Verifying; c.Verification = steam.VerifyAsync(steamId, ticket, config.RequireSteam); } void FinishJoin(Client c) { string error = c.Verification.Result; if (error != null) { Reject(c, error); return; } if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; } if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } // Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige // SteamID behaupten - damit darf er weder Admin werden noch einen verbundenen Spieler verdrängen. c.SteamVerified = steam.Enabled && !c.SteamId.StartsWith("name:"); foreach (var other in clients.Values) { if (other == c || other.State != ClientState.Joined || other.Conn.IsClosing || other.SteamId != c.SteamId) continue; if (c.SteamVerified) other.Conn.CloseAfterSend("Joined from another location"); // echter Account: alte Verbindung trennen else { Reject(c, "A player with this identity is already connected"); return; } } c.State = ClientState.Joined; c.JoinedAt = DateTime.UtcNow; var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList(); c.Conn.Send(MsgType.Welcome, Wire.Build(w => { w.Write(c.Id); w.Write(true); // dedizierter Server w.Write(joined.Count); foreach (var p in joined) { w.Write(p.Id); w.Write(p.Name); } })); Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(c.Id); w.Write(c.Name); }), c.Id); // Admin? Nur Spieler mit (geprüfter) SteamID können Admin sein c.IsAdmin = c.SteamVerified && config.Admins.Contains(c.SteamId); if (!c.SteamVerified && config.Admins.Contains(c.SteamId)) Log($"{c.Name} uses admin SteamID {c.SteamId}, but it cannot be verified (no SteamWebApiKey) - no admin rights"); 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. // 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(); } void Reject(Client c, string reason) { c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write(reason))); c.Conn.CloseAfterSend(reason); Log($"Rejected {c.Name} ({c.Conn.RemoteAddress}): {reason}"); } /// Spieler kicken oder bannen (Konsole oder Admin im Spiel); alle sehen es im Chat. string KickOrBan(Client target, bool ban, string reason, string by) { reason = (reason ?? "").Trim(); if (reason.Length > 100) reason = reason[..100]; if (ban && !config.BannedSteamIds.Contains(target.SteamId)) { config.BannedSteamIds.Add(target.SteamId); config.Save(configPath); } string what = ban ? "banned" : "kicked"; ServerChat($"{target.Name} was {what}" + (reason.Length > 0 ? $" ({reason})" : "")); Reject(target, $"You were {what} by {by}" + (reason.Length > 0 ? $": {reason}" : "")); Log($"{target.Name} was {what} by {by}" + (reason.Length > 0 ? $": {reason}" : "")); return $"{target.Name} was {what}"; } /// Chatnachricht vom Server an alle (Absender-ID 0 = "Server"). 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(); } /// /// 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. /// 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(); } /// Vorhandene Spielstand-Backups, neueste zuerst. List SaveBackups() { string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups"); return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List(); } /// Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten. 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 { } } /// /// 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. /// void ReconnectAll(string message) { int count = 0; foreach (var c in clients.Values.Where(x => x.State == ClientState.Joined).ToList()) { c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write(message))); c.Conn.CloseAfterSend("Reconnect after world change"); count++; } if (count > 0) Log($"{count} player(s) will reconnect"); } // ---------------------------------------------------------------- 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(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 restored ? "World reset to the original save game" : "World reset"; } /// Aktuellen Spielstand jetzt als Backup sichern. string BackupNow(string by) { if (!Directory.Exists(savePath)) return "No save game on the server yet - nothing to back up"; try { BackupSave(); Log($"Save game backed up by {by}"); return "Backup created: " + Path.GetFileName(SaveBackups().FirstOrDefault() ?? ""); } catch (Exception e) { return "Backup failed: " + e.Message; } } /// Backup wiederherstellen (Nummer aus 'backups' oder Name/Zeitstempel); alle verbinden neu. string RestoreSave(string arg, string by) { var list = SaveBackups(); string chosen = int.TryParse(arg, out int n) && n >= 1 && n <= list.Count ? list[n - 1] : list.FirstOrDefault(d => Path.GetFileName(d) == arg); if (chosen == null) return "restore-save (see 'backups')"; string name = Path.GetFileName(chosen); try { // Erst in einen Zwischenordner kopieren: BackupSave() unten könnte alte Backups (auch dieses) löschen string staging = Path.Combine(Path.GetDirectoryName(savePath)!, "save_restore_tmp"); if (Directory.Exists(staging)) Directory.Delete(staging, true); Directory.CreateDirectory(staging); foreach (string file in Directory.GetFiles(chosen)) File.Copy(file, Path.Combine(staging, Path.GetFileName(file)), true); BackupSave(); // aktuellen Stand ebenfalls sichern, falls man zurück will Directory.CreateDirectory(savePath); foreach (string file in Directory.GetFiles(staging)) File.Copy(file, Path.Combine(savePath, Path.GetFileName(file)), true); Directory.Delete(staging, true); if (!LoadSave()) return "Could not load the restored save game"; world.ResetForNewSave(); SaveWorld(); Log($"Restored save game {name} ({by})"); ReconnectAll(by == "Console" ? "The server restored an older save game - reconnecting..." : $"{by} restored the save game from {name} - reconnecting..."); return "Restored " + name; } catch (Exception e) { return "Could not restore: " + e.Message; } } string Unban(string id, string by) { id = (id ?? "").Trim(); if (!config.BannedSteamIds.Remove(id)) return $"{id} is not banned"; config.Save(configPath); Log($"{id} was unbanned by {by}"); return $"{id} is no longer banned"; } static string SpawnName(byte[] payload) { try { return new BinaryReader(new MemoryStream(payload)).ReadString(); } catch { return "an item"; } } void ServerChat(string text) { Broadcast(MsgType.Chat, Wire.WithSender(ServerId, Wire.Build(w => w.Write(text)))); } void Broadcast(MsgType type, byte[] payload, int except = -1) { byte[] frame = null; // nur einmal rahmen, nicht pro Spieler foreach (var c in clients.Values) if (c.Id != except && c.State == ClientState.Joined) c.Conn.SendFrame(frame ??= Wire.Frame(type, payload)); } readonly List waitList = new(); /// /// Bis zu ein paar Millisekunden warten, aber sofort weitermachen, sobald ein Spieler etwas schickt oder jemand /// verbinden will - schneller als festes Schlafen und ohne Leerlauf-Last. /// void WaitForTraffic() { waitList.Clear(); waitList.Add(listener.Server); foreach (var c in clients.Values) if (!c.Conn.IsClosed) waitList.Add(c.Conn.Socket); int micros = clients.Count == 0 ? 50_000 : 5_000; // leerer Server: seltener aufwachen try { Socket.Select(waitList, null, null, micros); } catch { Thread.Sleep(2); } // z.B. Socket wurde gerade geschlossen } // ---------------------------------------------------------------- Konsole void RunCommands() { while (actions.TryDequeue(out var action)) { try { action(); } catch (Exception e) { Log("Panel action failed: " + e.Message); } } while (commands.TryDequeue(out string line)) ExecuteCommand(line); } void ExecuteCommand(string line) { { // BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); if (args.Length == 0) return; switch (args[0].ToLowerInvariant()) { case "help": Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | panel | stop-game | start-game | restart | say | op | deop | admins | save | reset-world | reload-save | stop"); break; case "say": if (args.Length < 2) { Log("say "); break; } ServerChat(args[1]); Log("[chat] Server: " + args[1]); break; case "admins": Log("Admins: " + (config.Admins.Count == 0 ? "(none)" : string.Join(", ", config.Admins))); break; case "op": case "deop": { bool makeAdmin = args[0] == "op"; string target = args.Length > 1 ? args[1].Trim() : ""; // Direkt eine SteamID64 (17 Ziffern) - geht auch, wenn der Spieler gerade nicht online ist if (target.Length == 17 && target.All(char.IsDigit)) { config.Admins.Remove(target); if (makeAdmin) config.Admins.Add(target); config.Save(configPath); foreach (var online in clients.Values.Where(x => x.SteamId == target && x.SteamVerified)) { online.IsAdmin = makeAdmin; online.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(makeAdmin))); } Log($"SteamID {target} is {(makeAdmin ? "now an admin" : "no longer an admin")}"); break; } if (!int.TryParse(target, out int opId) || !clients.TryGetValue(opId, out var who)) { Log("Unknown player id (see 'players') - or use a SteamID64"); break; } if (!who.SteamVerified) { Log(steam.Enabled ? "Only players with Steam can be admins" : "Admins need a verified SteamID - set SteamWebApiKey in server.json (https://steamcommunity.com/dev/apikey)"); break; } config.Admins.Remove(who.SteamId); if (makeAdmin) config.Admins.Add(who.SteamId); config.Save(configPath); who.IsAdmin = makeAdmin; who.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(makeAdmin))); Log($"{who.Name} is {(makeAdmin ? "now an admin" : "no longer an admin")}"); break; } case "status": Log($"{config.Name} - {JoinedCount}/{config.MaxPlayers} players, {world.Objects.Count} objects, {world.Events.Count} actions"); break; case "players": foreach (var c in clients.Values.Where(c => c.State == ClientState.Joined)) Log($" [{c.Id}] {c.Name} SteamID {c.SteamId} {c.Conn.RemoteAddress}"); break; case "bans": Log("Banned: " + (config.BannedSteamIds.Count == 0 ? "(none)" : string.Join(", ", config.BannedSteamIds))); break; case "unban": Log(args.Length > 1 ? Unban(args[1], "Console") : "unban (see 'bans')"); break; case "kick": case "ban": { string[] parts = args.Length > 1 ? args[1].Split(' ', 2) : new string[0]; if (parts.Length < 1 || !int.TryParse(parts[0], out int id) || !clients.TryGetValue(id, out var target)) { Log("Unknown player id (see 'players')"); break; } Log(KickOrBan(target, args[0] == "ban", parts.Length > 1 ? parts[1] : "", "Console")); break; } case "save": SaveWorld(); break; case "panel": if (Panel != null) Panel.PrintInfo(); else Log("The web panel is disabled (PanelEnabled in server.json)"); break; case "reset-world": ResetWorld("Console"); break; case "backups": { var list = SaveBackups(); if (list.Count == 0) { Log("No save backups yet (they are created when the server takes over a save game)"); break; } Log("Save backups (newest first) - restore with: restore-save "); for (int i = 0; i < list.Count; i++) Log($" [{i + 1}] {Path.GetFileName(list[i])}"); break; } case "backup": Log(BackupNow("Console")); break; case "restore-save": Log(RestoreSave(args.Length > 1 ? args[1].Trim() : "", "Console")); break; case "reload-save": 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": case "exit": running = false; break; case "restart": Log("Restarting (settings, license and save game are loaded again) ..."); RestartRequested = true; running = false; break; case "stop-game": Log(StopGame("Console")); break; case "start-game": Log(StartGame("Console")); break; default: Log("Unknown command, type 'help'"); break; } } } /// /// Hinweis bei fehlenden Dateirechten (Linux/Docker: Dateien gehören einem anderen Benutzer, /// z.B. root oder per SFTP mit anderem Konto hochgeladen). /// public static string PermissionHint() { string uid = "?"; try { string line = File.ReadLines("/proc/self/status").FirstOrDefault(l => l.StartsWith("Uid:")); if (line != null) uid = line.Split(new[] { '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries)[1]; } catch { } // nicht Linux return uid == "?" ? "Check the file permissions of the data folder." : $"The server runs as user id {uid}. Fix on the host: sudo chown -R {uid}:{uid} data " + "(or set MSCO_UID / MSCO_GID in .env to your own id: `id -u`) - see README \"Permission denied\"."; } void SaveWorld() { try { world.Save(worldPath); Log($"World saved ({world.Objects.Count} objects, {world.Events.Count} actions)"); } catch (Exception e) { Log("Could not save world: " + e.Message); } } public static void Log(string text) { string time = DateTime.Now.ToString("HH:mm:ss"); Console.WriteLine($"[{time}] {text}"); LogBuffer.Add(time, text); } // ---------------------------------------------------------------- Web-Panel // Das Panel läuft auf eigenen Threads. Alles, was den Server-Zustand liest oder ändert, wird hier in die // Server-Schleife gegeben (höchstens ein paar Millisekunden Wartezeit) - so gibt es keine Überschneidungen. /// Funktion in der Server-Schleife ausführen und das Ergebnis zurückgeben. public Task Invoke(Func func) { var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); actions.Enqueue(() => { try { done.SetResult(func()); } catch (Exception e) { done.SetException(e); } }); return done.Task; } /// Konsolenbefehl ausführen und alles zurückgeben, was er ins Log schreibt. public string RunCommandCaptured(string line, string by) { Log($"> {line} ({by})"); long from = LogBuffer.LastSeq; ExecuteCommand(line); return string.Join("\n", LogBuffer.Since(from, 500).Select(l => l.Text)); } public object PanelStatus() { string resetPoint = File.Exists(Path.Combine(originalPath, "defaultES2File.txt")) ? File.GetLastWriteTimeUtc(Path.Combine(originalPath, "defaultES2File.txt")).ToString("o") : null; return new { name = config.Name, description = config.Description, version = ModVersion, players = JoinedCount, maxPlayers = config.MaxPlayers, gameStopped = GameStopped, uptimeSeconds = (long)clock.Elapsed.TotalSeconds, port = config.Port, isPublic = config.Public, requireSteam = config.RequireSteam, steamVerification = steam.Enabled, objects = world.Objects.Count, actions = world.Events.Count, boughtItems = world.Appeared.Count, admins = config.Admins.Count, bans = config.BannedSteamIds.Count, backups = SaveBackups().Count, resetPoint, saveSyncMinutes = config.SaveSyncMinutes }; } public object PanelPlayers(bool withAddress) => clients.Values.Where(c => c.State == ClientState.Joined && !c.Conn.IsClosing).OrderBy(c => c.Id).Select(c => new { id = c.Id, name = c.Name, steamId = c.SteamId, admin = c.IsAdmin, verified = c.SteamVerified, address = withAddress ? c.Conn.RemoteAddress : null, joinedAt = c.JoinedAt.ToString("o") }).ToList(); public object PanelBans() => config.BannedSteamIds.ToList(); // ---------------------------------------------------------------- Start / Stopp / Neustart /// Spiel offline (Panel läuft weiter): Spieler raus, nicht mehr gelistet, niemand kann beitreten. public bool GameStopped { get; private set; } /// Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden). public bool RestartRequested { get; private set; } public string StopGame(string by) { if (GameStopped) return "The game server is already stopped"; foreach (var c in clients.Values.Where(x => x.State == ClientState.Joined).ToList()) Reject(c, $"The server was stopped by {by}"); GameStopped = true; saveUpload = null; waitingForSave.Clear(); SaveWorld(); Log($"Game server stopped by {by} - players cannot join until it is started again (the panel keeps running)"); return "Game server stopped"; } public string StartGame(string by) { if (!GameStopped) return "The game server is already running"; if (!LoadSave()) return "Could not load the save game - see the console"; GameStopped = false; HeartbeatSoon(); Log($"Game server started by {by}"); return "Game server started"; } public string PanelServerAction(string action, string by) { switch (action) { case "stop-game": return StopGame(by); case "start-game": return StartGame(by); case "restart": Log($"Restart requested by {by} ..."); RestartRequested = true; running = false; return "Restarting - the panel is back in a few seconds"; case "shutdown": Log($"Shutdown requested by {by} - the server and the panel stop"); running = false; return "Shutting down"; default: return "Unknown action"; } } // ---------------------------------------------------------------- Einstellungen (server.json) im Panel public object PanelConfig() => new { name = config.Name, description = config.Description, maxPlayers = config.MaxPlayers, isPublic = config.Public, requireSteam = config.RequireSteam, publicAddress = config.PublicAddress, saveSyncMinutes = config.SaveSyncMinutes, autosaveSeconds = config.AutosaveSeconds, port = config.Port, panelEnabled = config.PanelEnabled, panelPort = config.PanelPort, panelBindAddress = config.PanelBindAddress, panelTrustProxy = config.PanelTrustProxy, panelBasePath = config.PanelBasePath, licenseKeySet = !string.IsNullOrWhiteSpace(config.LicenseKey), steamWebApiKeySet = !string.IsNullOrWhiteSpace(config.SteamWebApiKey), environmentOverrides = config.EnvironmentOverrides }; /// Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert. public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by) { if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings"; var changed = new List(); bool restartNeeded = false; string Text(string name, string current, int max) { if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current; string s = v.GetString().Trim(); if (s.Length > max) s = s[..max]; if (s != current) changed.Add(name); return s; } int Number(string name, int current, int min, int max) { if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current; n = Math.Clamp(n, min, max); if (n != current) changed.Add(name); return n; } bool Flag(string name, bool current) { if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current; bool f = v.GetBoolean(); if (f != current) changed.Add(name); return f; } string name = Text("name", config.Name, 60); if (name.Length == 0) return "The server name cannot be empty"; config.Name = name; config.Description = Text("description", config.Description, 300); config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64); config.Public = Flag("isPublic", config.Public); config.RequireSteam = Flag("requireSteam", config.RequireSteam); config.PublicAddress = Text("publicAddress", config.PublicAddress, 200); config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240); config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600); int before = changed.Count; config.Port = Number("port", config.Port, 1, 65535); config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled); config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535); config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60); config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy); config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60); if (changed.Count > before) restartNeeded = true; foreach (string secret in new[] { "licenseKey", "steamWebApiKey" }) { if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue; if (!owner) return "Only the owner can change keys"; string value = v.GetString().Trim(); if (value.Length > 200) return "Key too long"; if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value; changed.Add(secret); restartNeeded = true; } if (changed.Count == 0) return "Nothing changed"; try { config.Save(configPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; } HeartbeatSoon(); // Name/Beschreibung/Spielerzahl gleich an die Serverliste Log($"Settings changed by {by}: {string.Join(", ", changed)}"); var overridden = changed.Where(c => config.EnvironmentOverrides.Any(e => e.Replace("_", "").Contains(c.ToUpperInvariant().Replace("ISPUBLIC", "PUBLIC")))).ToList(); return "Settings saved" + (restartNeeded ? " - some changes need a restart" : "") + (overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : ""); } // ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen const long MaxSaveZip = 64L * 1024 * 1024; /// /// Spielstand aus einer ZIP übernehmen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem /// Ordner in der ZIP) - Pfade aus der ZIP werden nie benutzt. Danach: Backup, laden, neuer Reset-Punkt, Welt leer, /// alle verbinden neu. /// public string ImportSaveZip(byte[] zip, string by) { var found = new Dictionary(StringComparer.OrdinalIgnoreCase); try { using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(zip), System.IO.Compression.ZipArchiveMode.Read); long total = 0; foreach (var entry in archive.Entries) { string file = Path.GetFileName(entry.FullName.Replace('\\', '/')); string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase)); if (known == null) continue; if (entry.Length > MaxSaveZip || (total += entry.Length) > MaxSaveZip) return "The save game is too large"; using var s = entry.Open(); using var ms = new MemoryStream(); s.CopyTo(ms); found[known] = ms.ToArray(); } } catch (InvalidDataException) { return "This is not a valid ZIP file"; } if (!found.ContainsKey("defaultES2File.txt")) return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"; try { BackupSave(); Directory.CreateDirectory(savePath); foreach (string name in SaveFiles) { string target = Path.Combine(savePath, name); if (found.TryGetValue(name, out var data)) { File.WriteAllBytes(target + ".tmp", data); File.Move(target + ".tmp", target, true); } else if (File.Exists(target)) File.Delete(target); // gehört zum alten Spielstand } } catch (Exception e) { return "Could not write the save game: " + e.Message; } if (!LoadSave()) return "The uploaded save game could not be loaded - see the console"; SetResetPoint(); saveUpload = null; waitingForSave.Clear(); world.Reset(); SaveWorld(); Log($"New save game uploaded by {by} ({found.Count} files) - it is also the new reset point"); ReconnectAll($"{by} uploaded a new save game - reconnecting..."); return $"Save game uploaded ({found.Count} files) - everyone reconnects"; } /// Aktuellen Spielstand als ZIP. public byte[] ExportSaveZip() { using var ms = new MemoryStream(); using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, true)) { foreach (string name in SaveFiles) { string file = Path.Combine(savePath, name); if (File.Exists(file)) System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, file, name); } } return ms.ToArray(); } public object PanelBackups() => SaveBackups().Select(d => Path.GetFileName(d)).ToList(); public string PanelKick(int id, bool ban, string reason, string by) { if (!clients.TryGetValue(id, out var target) || target.State != ClientState.Joined) return "Player not found"; return KickOrBan(target, ban, reason, by); } /// SteamID sperren, auch wenn der Spieler gerade nicht online ist. public string PanelBanId(string steamId, string reason, string by) { steamId = (steamId ?? "").Trim(); bool valid = (steamId.Length == 17 && steamId.All(char.IsDigit)) || (steamId.StartsWith("name:") && steamId.Length > 5 && steamId.Length < 60); if (!valid) return "Enter a SteamID64 (17 digits) or name:playername"; var online = clients.Values.FirstOrDefault(c => c.State == ClientState.Joined && c.SteamId == steamId); if (online != null) return KickOrBan(online, true, reason, by); if (config.BannedSteamIds.Contains(steamId)) return steamId + " is already banned"; config.BannedSteamIds.Add(steamId); config.Save(configPath); Log($"{steamId} was banned by {by}"); return steamId + " is banned"; } public string PanelUnban(string id, string by) => Unban(id, by); public string PanelSay(string text, string by) { text = (text ?? "").Trim(); if (text.Length == 0) return "Empty message"; if (text.Length > 200) text = text[..200]; ServerChat(text); Log($"[chat] Server ({by}): {text}"); return "Sent"; } public string PanelSetAdmin(int id, bool admin, string by) => RunCommandCaptured((admin ? "op " : "deop ") + id, by); public string PanelWorld(string action, string arg, string by) { switch (action) { case "save": SaveWorld(); return "World state saved"; case "backup": return BackupNow(by); case "reset": return ResetWorld(by); case "restore": return RestoreSave(arg ?? "", by); case "resetpoint": if (!SetResetPoint()) return "No save game to use as reset point"; Log($"{by} set the current save game as the reset point"); return "The current save game is now the reset point"; case "reloadsave": return RunCommandCaptured("reload-save", by); default: return "Unknown action"; } } }