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 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 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"); 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() { 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) { // Grund mitschicken: die Spieler sehen ihn im Hauptmenü if (c.State == ClientState.Joined) c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write("The server was shut down."))); 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."); } void AcceptClients() { while (listener.Pending()) { var c = new Client { Id = nextId++, Conn = new Connection(listener.AcceptTcpClient()), State = ClientState.New }; clients[c.Id] = c; } } 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 && 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 && 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; /// 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.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); })); 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 (!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 (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 (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; 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"); } 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 (commands.TryDequeue(out string line)) { // BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); if (args.Length == 0) continue; switch (args[0].ToLowerInvariant()) { case "help": Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | restore-save | 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 "reset-world": world.Reset(); SaveWorld(); Log("World state reset - new players get the plain save game again."); ReconnectAll("The world is being reset - reconnecting..."); 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 "restore-save": { var list = SaveBackups(); string arg = args.Length > 1 ? args[1].Trim() : ""; 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) { Log("restore-save (see 'backups')"); break; } try { BackupSave(); // aktuellen Stand ebenfalls sichern, falls man zurück will foreach (string file in Directory.GetFiles(chosen)) File.Copy(file, Path.Combine(savePath, Path.GetFileName(file)), true); if (LoadSave()) { world.ResetForNewSave(); SaveWorld(); Log($"Restored save game {Path.GetFileName(chosen)}"); ReconnectAll("The server restored an older save game - reconnecting..."); } } catch (Exception e) { Log("Could not restore: " + e.Message); } break; } case "reload-save": if (LoadSave()) ReconnectAll("The server loaded a new save game - reconnecting..."); break; case "stop": case "quit": case "exit": running = false; 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) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {text}"); }