Files
MSCO-Dedicated-Server/GameServer.cs
T

1239 lines
56 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
namespace MSCO.Server;
/// <summary>
/// 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.
/// </summary>
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 string[] SaveFiles => SaveStore.SaveFiles;
string DataDir => Path.GetDirectoryName(savePath)!;
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;
/// <summary>SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte.</summary>
public bool SteamVerified;
public bool IsAdmin;
public DateTime JoinedAt;
public DateTime DestroyWindowStart;
public int DestroyCount;
/// <summary>
/// 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.
/// </summary>
public DateTime TrackPositionFrom = DateTime.MaxValue;
public Task<string> 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<int, Client> clients = new();
readonly ConcurrentQueue<string> commands = new();
readonly ConcurrentQueue<Action> actions = new(); // vom Web-Panel: laufen in der Server-Schleife
readonly Stopwatch clock = Stopwatch.StartNew();
// Neue Verbindungen reicht der Host herein (er besitzt den Port und erkennt Browser fürs Panel)
readonly ConcurrentQueue<TcpClient> incoming = new();
readonly AutoResetEvent incomingSignal = new(false);
/// <summary>Vom Host: eine neue Spiel-Verbindung.</summary>
public void AcceptExternal(TcpClient tcp)
{
incoming.Enqueue(tcp);
incomingSignal.Set();
}
List<KeyValuePair<string, byte[]>> saveFiles = new();
int nextId = 1;
byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand
double nextAutosave, nextHeartbeat;
Task<MasterClient.HeartbeatResult> 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<string, byte[]> Files = new();
}
SaveUpload saveUpload;
int nextSaveRequestId = 1;
double nextSaveSync;
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
const long MaxUploadBytes = 32L * 1024 * 1024;
/// <summary>So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten.</summary>
public double SaveUploadTimeoutSeconds { get; set; } = 15;
const int MaxDestroyPerMinute = 20;
/// <summary>Ohne Master-Server (nur für automatische Tests): keine Heartbeats, keine Serverliste.</summary>
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<KeyValuePair<string, byte[]>>();
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();
Log($"Game server running 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)
{
if (c.State != ClientState.Joined) { c.Conn.CloseAfterSend("Server shutting down"); continue; }
if (stoppedBy != null && !RestartRequested)
{
// Gezielt gestoppt (Panel/Konsole): Grund zeigen, nicht endlos neu verbinden
c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write($"The server was stopped by {stoppedBy}")));
}
else
{
// 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ü.
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");
SaveWorld();
if (!offline)
{
master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste
Log("Removed from the server list.");
}
Log("Server stopped.");
}
/// <summary>Web-Panel (gehört dem Host, läuft auch, wenn dieser Server aus ist).</summary>
public AdminPanel Panel { get; set; }
public ServerHost Host { get; set; }
void AcceptClients()
{
while (incoming.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;
}
void PollClients()
{
List<Client> 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
}
}
/// <summary>Spielerzahl hat sich geändert: gleich beim Master melden statt erst in 30 s.</summary>
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);
}
}
/// <summary>Erreichbarkeit laut Master nur bei Änderung ausgeben.</summary>
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;
/// <summary>Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients).</summary>
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 (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;
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}");
}
/// <summary>Spieler kicken oder bannen (Konsole oder Admin im Spiel); alle sehen es im Chat.</summary>
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}";
}
/// <summary>Chatnachricht vom Server an alle (Absender-ID 0 = "Server").</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Vorhandene Spielstand-Backups, neueste zuerst.</summary>
List<string> SaveBackups() => SaveStore.BackupDirs(DataDir);
/// <summary>Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
void BackupSave() => SaveStore.Backup(DataDir);
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Beim Start: gibt es noch keinen Reset-Punkt, ist es der aktuelle Spielstand.</summary>
void EnsureResetPoint()
{
if (File.Exists(Path.Combine(originalPath, "defaultES2File.txt"))) return;
if (SetResetPoint()) Log("Remembered the current save game as the reset point (reset-world goes back to it)");
}
/// <summary>Aktuellen Spielstand als Reset-Punkt merken.</summary>
bool SetResetPoint()
{
string error = SaveStore.SetResetPoint(DataDir);
if (error != null && SaveStore.HasSave(DataDir)) Log(error);
return error == null;
}
/// <summary>
/// Welt komplett zurücksetzen (Konsole oder Admin im Spiel): Spielstand zurück auf den Reset-Punkt, alle
/// gespeicherten Änderungen (Positionen, gekaufte Items, Türen, Fahrzeugwerte ...) weg, alle verbinden neu.
/// Der Stand davor landet als Backup in save_backups.
/// </summary>
string ResetWorld(string by)
{
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";
}
/// <summary>Aktuellen Spielstand jetzt als Backup sichern.</summary>
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; }
}
/// <summary>Backup wiederherstellen (Nummer aus 'backups' oder Name/Zeitstempel); alle verbinden neu.</summary>
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 <number> (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<Socket> waitList = new();
/// <summary>
/// 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.
/// </summary>
void WaitForTraffic()
{
waitList.Clear();
foreach (var c in clients.Values)
if (!c.Conn.IsClosed) waitList.Add(c.Conn.Socket);
if (waitList.Count == 0)
{
incomingSignal.WaitOne(50); // leerer Server: warten, bis jemand verbindet (oder Timer fällig)
return;
}
try { Socket.Select(waitList, null, null, 5_000); }
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 <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | panel | restart | stop | exit | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
break;
case "say":
if (args.Length < 2) { Log("say <text>"); 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 <SteamID64 or name:playername> (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 <number>");
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":
stoppedBy = "Console";
running = false;
Log(Panel != null ? "Stopping the game server - the web panel keeps running ('start' to start again, 'exit' to quit)" : "Stopping ...");
break;
case "quit":
case "exit":
RequestExit();
break;
case "restart":
Log("Restarting (settings, license and save game are loaded again) ...");
RestartRequested = true;
running = false;
break;
default:
Log("Unknown command, type 'help'");
break;
}
}
}
/// <summary>
/// Hinweis bei fehlenden Dateirechten (Linux/Docker: Dateien gehören einem anderen Benutzer,
/// z.B. root oder per SFTP mit anderem Konto hochgeladen).
/// </summary>
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.
/// <summary>Funktion in der Server-Schleife ausführen und das Ergebnis zurückgeben.</summary>
public Task<T> Invoke<T>(Func<T> func)
{
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
actions.Enqueue(() =>
{
try { done.SetResult(func()); }
catch (Exception e) { done.SetException(e); }
});
return done.Task;
}
/// <summary>Konsolenbefehl ausführen und alles zurückgeben, was er ins Log schreibt.</summary>
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,
state = "running",
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();
/// <summary>Ingame-Admins (SteamID64) mit Namen, falls gerade online.</summary>
public object PanelAdmins() => new
{
steamVerification = steam.Enabled,
admins = config.Admins.Select(id =>
{
var online = clients.Values.FirstOrDefault(c => c.State == ClientState.Joined && c.SteamId == id);
return new { steamId = id, name = online?.Name, online = online != null };
}).ToList()
};
// ---------------------------------------------------------------- Stopp / Neustart (der Host startet neu oder wartet)
/// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary>
public bool RestartRequested { get; private set; }
/// <summary>Ganzes Programm beenden (Konsole "exit", docker stop) - sonst bleibt das Panel an.</summary>
public bool ExitRequested { get; private set; }
string stoppedBy; // gesetzt = gezielt gestoppt (Spieler bekommen den Grund statt "verbinde neu")
public void RequestExit()
{
ExitRequested = true;
running = false;
}
public string PanelServerAction(string action, string by)
{
switch (action)
{
case "stop":
Log($"Server stopped by {by} - the panel keeps running");
stoppedBy = by;
running = false;
return "Stopping the server";
case "restart":
Log($"Restart requested by {by} ...");
RestartRequested = true;
running = false;
return "Restarting - the panel is back in a few seconds";
default: return "Unknown action";
}
}
// ---------------------------------------------------------------- Einstellungen (server.json) im Panel
public object PanelConfig() => ConfigEditor.Build(config);
public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by)
{
string result = ConfigEditor.Apply(config, configPath, b, owner, by);
HeartbeatSoon(); // Name/Beschreibung/Spielerzahl gleich an die Serverliste
return result;
}
// ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen
/// <summary>
/// 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.
/// </summary>
public string ImportSaveZip(byte[] zip, string by)
{
string error = SaveStore.ExtractArchive(zip, out var found);
if (error != null) return error;
try
{
BackupSave();
SaveStore.WriteSave(DataDir, found);
}
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";
}
/// <summary>Aktuellen Spielstand als ZIP.</summary>
public byte[] ExportSaveZip() => SaveStore.ExportZip(DataDir);
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);
}
/// <summary>SteamID sperren, auch wenn der Spieler gerade nicht online ist.</summary>
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";
}
}
}