Files
MSCO-Dedicated-Server/GameServer.cs
T

804 lines
36 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;
// ---------------------------------------------------------------- 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;
const int KeepSaveBackups = 5;
/// <summary>So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten.</summary>
public double SaveUploadTimeoutSeconds { get; set; } = 15;
/// <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 && !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()
{
listener = new TcpListener(IPAddress.Any, config.Port);
listener.Start();
Log($"Listening on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)");
if (!config.RequireSteam) Log("Non-Steam server: players without Steam can join (RequireSteam = false)");
else Log(steam.Enabled ? "Steam required - tickets are verified with the Steam Web API" : "Steam required (no Web API key set, tickets are not verified online)");
Log($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions");
if (!steam.Enabled && config.Admins.Count > 0)
Log("WARNING: Admins are disabled - without SteamWebApiKey anyone could pretend to be an admin. " +
"Get a free key at https://steamcommunity.com/dev/apikey and put it into server.json (\"SteamWebApiKey\").");
Log("Type 'help' for commands.");
nextHeartbeat = 0;
nextAutosave = config.AutosaveSeconds;
while (running)
{
AcceptClients();
PollClients();
RunCommands();
Timers();
Thread.Sleep(2);
}
foreach (var c in clients.Values)
{
// Grund mitschicken: die Spieler sehen ihn im Hauptmenü
if (c.State == ClientState.Joined) c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write("The server was shut down.")));
c.Conn.CloseAfterSend("Server shutting down");
}
// Kurz warten, bis die Spieler die Meldung gelesen haben (sauber trennen statt hart abbrechen)
var waitUntil = DateTime.UtcNow.AddSeconds(1.5);
while (DateTime.UtcNow < waitUntil && clients.Values.Any(c => !c.Conn.IsClosed))
{
foreach (var c in clients.Values) c.Conn.Poll();
Thread.Sleep(20);
}
foreach (var c in clients.Values) c.Conn.Close("Server shutting down");
listener.Stop();
SaveWorld();
if (!offline)
{
master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste
Log("Removed from the server list.");
}
Log("Server stopped.");
}
void AcceptClients()
{
while (listener.Pending())
{
var c = new Client { Id = nextId++, Conn = new Connection(listener.AcceptTcpClient()), State = ClientState.New };
clients[c.Id] = c;
}
}
void PollClients()
{
List<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;
/// <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.AdminListRequest:
if (c.State != ClientState.Joined) return;
if (!c.IsAdmin) { c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write("You are not an admin on this server"))); return; }
var joinedPlayers = clients.Values.Where(x => x.State == ClientState.Joined).ToList();
c.Conn.Send(MsgType.AdminList, Wire.Build(w =>
{
w.Write(joinedPlayers.Count);
foreach (var p in joinedPlayers) { w.Write(p.Id); w.Write(p.Name); w.Write(p.SteamId); w.Write(p.IsAdmin); }
// Gebannte Spieler (für UNBAN) - am Ende angehängt, ältere Mods lesen hier nicht weiter
w.Write(config.BannedSteamIds.Count);
foreach (string id in config.BannedSteamIds) w.Write(id);
}));
return;
case MsgType.AdminAction:
{
if (c.State != ClientState.Joined) return;
var r = m.Reader();
byte action = r.ReadByte();
int targetId = r.ReadInt32();
string reason = r.ReadString();
string result;
if (!c.IsAdmin) result = "You are not an admin on this server";
else if (action == 4) result = Unban(reason, c.Name); // "reason" enthält hier die gebannte ID
else if (!clients.TryGetValue(targetId, out var target) || target.State != ClientState.Joined) result = "Player not found";
else if (action == 3)
{
// Spieler zum Admin holen: Position kommt vom Admin
if (r.BaseStream.Length - r.BaseStream.Position < 12) return;
float x = r.ReadSingle(), y = r.ReadSingle(), z = r.ReadSingle();
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z)) return;
target.Conn.Send(MsgType.Teleport, Wire.Build(w => { w.Write(x); w.Write(y); w.Write(z); }));
result = $"Brought {target.Name} to you";
Log($"{c.Name} teleported {target.Name} to themselves");
}
else if (target.IsAdmin && target != c) result = "You cannot kick or ban another admin";
else result = KickOrBan(target, action == 2, reason, c.Name);
c.Conn.Send(MsgType.AdminResult, Wire.Build(w => w.Write(result)));
return;
}
}
if (c.State != ClientState.Joined || !IsGameMessage(m.Type)) return;
// Weltzustand mitschreiben
switch (m.Type)
{
case MsgType.ObjStates: world.ApplyObjStates(m.Payload); break;
case MsgType.ObjRelease: world.ApplyObjRelease(m.Payload); break;
case MsgType.FsmEvent: world.AddFsmEvent(m.Payload); break;
case MsgType.Player:
if (DateTime.UtcNow >= c.TrackPositionFrom) world.ApplyPlayer(c.SteamId, m.Payload);
break;
case MsgType.MoneyDelta: world.ApplyMoney(m.Payload); break;
case MsgType.DoorState: world.ApplyDoor(m.Payload); break;
case MsgType.VarSync: world.ApplyVars(m.Payload); break;
case MsgType.SpawnItem:
if (!c.IsAdmin) return; // nur Admins dürfen Items spawnen
world.AddSpawn(m.Payload);
Log($"{c.Name} spawned {SpawnName(m.Payload)}");
break;
case MsgType.Chat:
if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten
Log($"[chat] {c.Name}: {m.Reader().ReadString()}");
break;
case MsgType.WorldTime:
if (c.Id != WorldAuthorityId) return; // nur einer gibt Uhrzeit/Wetter vor
lastWorldTime = m.Payload;
break;
}
// an alle anderen weiterleiten, mit Absender
Broadcast(m.Type, Wire.WithSender(c.Id, m.Payload), c.Id);
}
void BeginJoin(Client c, BinaryReader r)
{
string version = r.ReadString();
c.Name = r.ReadString();
ulong steamId = 0;
byte[] ticket = null;
if (r.BaseStream.Position < r.BaseStream.Length)
{
steamId = r.ReadUInt64();
ticket = r.ReadBytes(r.ReadInt32());
}
c.Name = string.IsNullOrWhiteSpace(c.Name) ? "Player" : c.Name.Trim();
if (c.Name.Length > 24) c.Name = c.Name[..24];
// Spieler ohne Steam (Non-Steam-Server) werden über ihren Namen wiedererkannt (Position, Sperren)
c.SteamId = steamId != 0 ? steamId.ToString() : "name:" + c.Name.ToLowerInvariant();
// Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken
if (version != ModVersion) Log($"{c.Name} uses MSCO {version} (server {ModVersion}) - syncing may not work correctly");
if (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; }
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
c.State = ClientState.Verifying;
c.Verification = steam.VerifyAsync(steamId, ticket, config.RequireSteam);
}
void FinishJoin(Client c)
{
string error = c.Verification.Result;
if (error != null) { Reject(c, error); return; }
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
// Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige
// SteamID behaupten - damit darf er weder Admin werden noch einen verbundenen Spieler verdrängen.
c.SteamVerified = steam.Enabled && !c.SteamId.StartsWith("name:");
foreach (var other in clients.Values)
{
if (other == c || other.State != ClientState.Joined || other.Conn.IsClosing || other.SteamId != c.SteamId) continue;
if (c.SteamVerified) other.Conn.CloseAfterSend("Joined from another location"); // echter Account: alte Verbindung trennen
else { Reject(c, "A player with this identity is already connected"); return; }
}
c.State = ClientState.Joined;
var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList();
c.Conn.Send(MsgType.Welcome, Wire.Build(w =>
{
w.Write(c.Id);
w.Write(true); // dedizierter Server
w.Write(joined.Count);
foreach (var p in joined) { w.Write(p.Id); w.Write(p.Name); }
}));
Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(c.Id); w.Write(c.Name); }), c.Id);
// Admin? Nur Spieler mit (geprüfter) SteamID können Admin sein
c.IsAdmin = c.SteamVerified && config.Admins.Contains(c.SteamId);
if (!c.SteamVerified && config.Admins.Contains(c.SteamId))
Log($"{c.Name} uses admin SteamID {c.SteamId}, but it cannot be verified (no SteamWebApiKey) - no admin rights");
bool isAdmin = c.IsAdmin;
c.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(isAdmin)));
// Spielstand schicken; nach dem Laden fragt der Client mit Ready nach dem Weltzustand.
// Spielt schon jemand, erst dessen aktuellen Spielstand holen (Schrauben, Teile ... genau wie jetzt im Spiel).
if (RequestSave("new player " + c.Name)) waitingForSave.Add(c);
else SendSave(c);
Log($"{c.Name} joined (SteamID {c.SteamId}, {c.Conn.RemoteAddress}) - {JoinedCount}/{config.MaxPlayers}");
HeartbeatSoon();
}
void Reject(Client c, string reason)
{
c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write(reason)));
c.Conn.CloseAfterSend(reason);
Log($"Rejected {c.Name} ({c.Conn.RemoteAddress}): {reason}");
}
/// <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>Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
void BackupSave()
{
if (!Directory.Exists(savePath)) return;
string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups");
string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss"));
Directory.CreateDirectory(target);
foreach (string file in Directory.GetFiles(savePath)) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true);
foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepSaveBackups))
try { Directory.Delete(old, true); } catch { }
}
/// <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");
}
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}");
}