651 lines
29 KiB
C#
651 lines
29 KiB
C#
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)
|
||
{
|
||
// 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.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
|
||
or MsgType.SpawnItem;
|
||
|
||
/// <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); }
|
||
// 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.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>
|
||
/// <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.Close("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 <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | 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 "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 "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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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}");
|
||
}
|