Files
MSCO-Dedicated-Server/GameServer.cs
T

598 lines
27 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.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;
/// <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;
/// <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 Stopwatch clock = Stopwatch.StartNew();
TcpListener listener;
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;
/// <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");
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);
// ---------------------------------------------------------------- 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()
{
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) 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<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();
}
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;
/// <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.Close("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.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); }
}));
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 (!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.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.SteamId != c.SteamId) continue;
if (c.SteamVerified) other.Conn.Close("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
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)));
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.Close(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 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 <id> [reason] | ban <id> [reason] | 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 "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.");
break;
case "reload-save":
LoadSave();
break;
case "stop":
case "quit":
case "exit":
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) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {text}");
}