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.7"; // 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; /// /// 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; /// 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(); Thread.Sleep(2); } 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; /// 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.Chat: if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten Log($"[chat] {c.Name}: {m.Reader().ReadString()}"); 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) { foreach (var c in clients.Values) if (c.Id != except && c.State == ClientState.Joined) c.Conn.Send(type, payload); } // ---------------------------------------------------------------- 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}"); }