diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c6be4d6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+bin/
+obj/
+data/
+publish/
+.git/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7a53975
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+bin/
+obj/
+data/
+*.user
+.vs/
+publish/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..07f3d3e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+# MSCO dedizierter Server
+# docker build -t msco-server MSCO.Server
+# docker run -it -p 7777:7777 -v msco-server-data:/data msco-server
+# Daten (server.json, save/, world.json) liegen im Volume /data.
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+COPY . .
+RUN dotnet publish -c Release -o /app
+
+FROM mcr.microsoft.com/dotnet/runtime:10.0
+WORKDIR /app
+COPY --from=build /app .
+ENV MSCO_DATA=/data
+# Befehle an den laufenden Server: docker exec msco-server msco players
+RUN printf '#!/bin/sh\nexec dotnet /app/MSCO.Server.dll --cmd "$@"\n' > /usr/local/bin/msco && chmod +x /usr/local/bin/msco
+VOLUME /data
+EXPOSE 7777/tcp
+# docker stop schickt SIGTERM -> Server speichert den Weltzustand
+STOPSIGNAL SIGTERM
+ENTRYPOINT ["dotnet", "MSCO.Server.dll"]
diff --git a/GameServer.cs b/GameServer.cs
new file mode 100644
index 0000000..987b1fa
--- /dev/null
+++ b/GameServer.cs
@@ -0,0 +1,524 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MSCO.Server;
+
+///
+/// Dedizierter Server: Verteiler zwischen den Spielern (wie der Host bei P2P, aber ohne eigenes Spiel),
+/// hält den Spielstand und den Weltzustand und schickt beides an neue Spieler.
+/// Die Physik simuliert weiterhin der Spieler, der ein Objekt gerade besitzt.
+///
+public sealed class GameServer
+{
+ public const string ModVersion = "2.6"; // 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;
+ public bool IsAdmin;
+ public Task Verification;
+ }
+
+ readonly ServerConfig config;
+ readonly string configPath;
+ readonly string ownerName; // Besitzer des Lizenzschlüssels (Server-Profil im Browser)
+ readonly string savePath;
+ readonly string worldPath;
+ readonly MasterClient master;
+ readonly SteamVerifier steam;
+ readonly WorldState world;
+ readonly Dictionary clients = new();
+ readonly ConcurrentQueue commands = new();
+ readonly Stopwatch clock = Stopwatch.StartNew();
+
+ TcpListener listener;
+ List> saveFiles = new();
+ int nextId = 1;
+ byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand
+ double nextAutosave, nextHeartbeat;
+ Task heartbeat;
+ bool? lastReachable;
+ static readonly TimeSpan ClientTimeout = TimeSpan.FromSeconds(30);
+ bool running = true;
+
+ public GameServer(ServerConfig config, string dataDir, string configPath, string ownerName)
+ {
+ 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()
+ {
+ saveFiles = new List>();
+ Directory.CreateDirectory(savePath);
+ foreach (string name in SaveFiles)
+ {
+ string file = Path.Combine(savePath, name);
+ if (File.Exists(file)) saveFiles.Add(new(name, File.ReadAllBytes(file)));
+ }
+ if (saveFiles.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;
+ }
+ 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");
+ 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();
+ master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste
+ Log("Removed from the server list.");
+ Log("Server stopped.");
+ }
+
+ void AcceptClients()
+ {
+ while (listener.Pending())
+ {
+ var c = new Client { Id = nextId++, Conn = new Connection(listener.AcceptTcpClient()), State = ClientState.New };
+ clients[c.Id] = c;
+ }
+ }
+
+ void PollClients()
+ {
+ List closed = null;
+ foreach (var c in clients.Values.ToList())
+ {
+ foreach (var m in c.Conn.Poll())
+ {
+ try { Handle(c, m); }
+ catch (Exception e) { c.Conn.Close("Bad message: " + e.Message); }
+ }
+
+ if (c.State == ClientState.Verifying && c.Verification.IsCompleted) FinishJoin(c);
+
+ // Abgebrochene Verbindungen (Absturz, Netz weg) melden sich nicht ab - ohne Lebenszeichen raus,
+ // sonst zählen sie ewig als Spieler mit
+ if (!c.Conn.IsClosed && DateTime.UtcNow - c.Conn.LastReceiveUtc > ClientTimeout)
+ c.Conn.Close("Timed out");
+
+ if (c.Conn.IsClosed) (closed ??= new()).Add(c);
+ }
+ if (closed == null) return;
+
+ foreach (var c in closed)
+ {
+ clients.Remove(c.Id);
+ if (c.State != ClientState.Joined) continue;
+ Log($"{c.Name} left ({c.Conn.CloseReason})");
+ Broadcast(MsgType.PlayerLeft, Wire.Build(w => w.Write(c.Id)));
+ HeartbeatSoon();
+ }
+ }
+
+ /// Spielerzahl hat sich geändert: gleich beim Master melden statt erst in 30 s.
+ void HeartbeatSoon() => nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 1);
+
+ void Timers()
+ {
+ double now = clock.Elapsed.TotalSeconds;
+ if (now >= nextAutosave)
+ {
+ nextAutosave = now + config.AutosaveSeconds;
+ if (world.Dirty) SaveWorld();
+ }
+
+ 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 (now >= nextHeartbeat && heartbeat == null)
+ {
+ nextHeartbeat = now + HeartbeatSeconds;
+ heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
+ }
+ }
+
+ /// Erreichbarkeit laut Master nur bei Änderung ausgeben.
+ void ReportReachability(MasterClient.HeartbeatResult r)
+ {
+ // Prüfung läuft noch: in ein paar Sekunden nachfragen statt erst in 30 s
+ if (r.Reachable == null) nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 6);
+ if (r.Reachable == null || r.Reachable == lastReachable) return;
+ lastReachable = r.Reachable;
+ if (r.Reachable == true)
+ Log($"Reachable from the internet at {r.Address}:{r.Port}" + (r.Listed ? " - listed in the server browser" : " (not public)"));
+ else
+ Log($"NOT reachable from the internet at {r.Address}:{r.Port} - not listed! Forward TCP port {r.Port} " +
+ "in your router/firewall, or set PublicAddress / MSCO_PUBLIC_ADDRESS if the address is wrong.");
+ }
+
+ // ---------------------------------------------------------------- Nachrichten
+
+ static bool IsGameMessage(MsgType t) =>
+ t is MsgType.Player or MsgType.ObjStates or MsgType.ObjOwn or MsgType.ObjRelease or MsgType.FsmEvent
+ or MsgType.VehicleFx or MsgType.WorldTime or MsgType.MoneyDelta or MsgType.DoorState or MsgType.Chat or MsgType.VarSync;
+
+ /// Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients).
+ int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min();
+
+ void Handle(Client c, Message m)
+ {
+ switch (m.Type)
+ {
+ case MsgType.Info:
+ // Server-Browser: Infos + Ping, danach Verbindung schließen
+ c.Conn.Send(MsgType.InfoReply, Wire.Build(w =>
+ {
+ w.Write(config.Name);
+ w.Write(config.Description ?? "");
+ w.Write(JoinedCount);
+ w.Write(config.MaxPlayers);
+ w.Write(ModVersion);
+ // Erweiterung für das Server-Profil im Browser (ältere Mods lesen hier nicht weiter)
+ w.Write(config.RequireSteam);
+ w.Write(ownerName ?? "");
+ w.Write((int)clock.Elapsed.TotalSeconds);
+ var names = clients.Values.Where(x => x.State == ClientState.Joined).Select(x => x.Name).ToList();
+ w.Write(names.Count);
+ foreach (string n in names) w.Write(n);
+ }));
+ c.Conn.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.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:
+ 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 (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: 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; }
+
+ // Gleicher Steam-Account nochmal verbunden: alte Verbindung trennen
+ foreach (var other in clients.Values)
+ if (other != c && other.State == ClientState.Joined && other.SteamId == c.SteamId)
+ other.Conn.Close("Joined from another location");
+
+ 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.SteamId.StartsWith("name:") && config.Admins.Contains(c.SteamId);
+ 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}");
+ }
+
+ /// Spieler kicken oder bannen (Konsole oder Admin im Spiel); alle sehen es im Chat.
+ string KickOrBan(Client target, bool ban, string reason, string by)
+ {
+ reason = (reason ?? "").Trim();
+ if (reason.Length > 100) reason = reason[..100];
+ if (ban && !config.BannedSteamIds.Contains(target.SteamId))
+ {
+ config.BannedSteamIds.Add(target.SteamId);
+ config.Save(configPath);
+ }
+ string what = ban ? "banned" : "kicked";
+ ServerChat($"{target.Name} was {what}" + (reason.Length > 0 ? $" ({reason})" : ""));
+ Reject(target, $"You were {what} by {by}" + (reason.Length > 0 ? $": {reason}" : ""));
+ Log($"{target.Name} was {what} by {by}" + (reason.Length > 0 ? $": {reason}" : ""));
+ return $"{target.Name} was {what}";
+ }
+
+ /// Chatnachricht vom Server an alle (Absender-ID 0 = "Server").
+ void ServerChat(string text)
+ {
+ Broadcast(MsgType.Chat, Wire.WithSender(ServerId, Wire.Build(w => w.Write(text))));
+ }
+
+ void Broadcast(MsgType type, byte[] payload, int except = -1)
+ {
+ foreach (var c in clients.Values)
+ if (c.Id != except && c.State == ClientState.Joined) c.Conn.Send(type, payload);
+ }
+
+ // ---------------------------------------------------------------- Konsole
+
+ void RunCommands()
+ {
+ while (commands.TryDequeue(out string line))
+ {
+ // BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren
+ string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ if (args.Length == 0) continue;
+ switch (args[0].ToLowerInvariant())
+ {
+ case "help":
+ Log("status | players | kick [reason] | ban [reason] | say | op | deop | admins | save | reset-world | reload-save | stop");
+ break;
+ case "say":
+ if (args.Length < 2) { Log("say "); break; }
+ ServerChat(args[1]);
+ Log("[chat] Server: " + args[1]);
+ break;
+ case "admins":
+ Log("Admins: " + (config.Admins.Count == 0 ? "(none)" : string.Join(", ", config.Admins)));
+ break;
+ case "op":
+ case "deop":
+ {
+ bool makeAdmin = args[0] == "op";
+ string target = args.Length > 1 ? args[1].Trim() : "";
+ // Direkt eine SteamID64 (17 Ziffern) - geht auch, wenn der Spieler gerade nicht online ist
+ if (target.Length == 17 && target.All(char.IsDigit))
+ {
+ config.Admins.Remove(target);
+ if (makeAdmin) config.Admins.Add(target);
+ config.Save(configPath);
+ foreach (var online in clients.Values.Where(x => x.SteamId == target))
+ {
+ 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.SteamId.StartsWith("name:"))
+ {
+ Log("Only players with Steam can be admins (their SteamID is verified)");
+ 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;
+ }
+ }
+ }
+
+ 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}");
+}
diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj
new file mode 100644
index 0000000..3f2f464
--- /dev/null
+++ b/MSCO.Server.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net10.0
+ disable
+ enable
+ MSCO.Server
+ MSCO.Server
+ true
+
+ logo.ico
+
+
+
diff --git a/MasterClient.cs b/MasterClient.cs
new file mode 100644
index 0000000..44d6f41
--- /dev/null
+++ b/MasterClient.cs
@@ -0,0 +1,101 @@
+using System.Net.Http.Json;
+
+namespace MSCO.Server;
+
+///
+/// Alle Server hängen am offiziellen Master (Keymaster + Serverliste). Die Adresse ist fest eingebaut,
+/// damit niemand die Schlüsselprüfung über einen eigenen Master umgehen kann.
+/// Nur Debug-Builds (lokales Testen) dürfen sie mit MSCO_MASTER_URL umstellen.
+///
+public static class MasterServer
+{
+ public const string OfficialUrl = "https://msco.mbm.wtf";
+
+#if DEBUG
+ public static string Url
+ {
+ get
+ {
+ string custom = Environment.GetEnvironmentVariable("MSCO_MASTER_URL");
+ return string.IsNullOrWhiteSpace(custom) ? OfficialUrl : custom.Trim().TrimEnd('/');
+ }
+ }
+#else
+ public static string Url => OfficialUrl;
+#endif
+}
+
+/// Spricht mit dem Master-Server: Lizenzschlüssel prüfen und Server in der Liste melden.
+public sealed class MasterClient
+{
+ public sealed record KeyInfo(bool Valid, string Owner, string Label, string Error)
+ {
+ /// Master war nicht erreichbar (Netzwerk, Proxy-Fehler) - dann später erneut versuchen.
+ public bool Unreachable { get; init; }
+ }
+ record HeartbeatBody(string Key, string Name, string Description, string Address, int Port, int Players, int MaxPlayers,
+ string Version, bool Public, bool RequireSteam);
+
+ readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) };
+ readonly string baseUrl;
+
+ public MasterClient(string masterUrl)
+ {
+ baseUrl = masterUrl.TrimEnd('/');
+ }
+
+ public async Task ValidateKeyAsync(string key)
+ {
+ try
+ {
+ var response = await http.PostAsJsonAsync(baseUrl + "/api/keys/validate", new { key });
+ // 5xx (z.B. 502 vom Reverse Proxy, Master gerade nicht da) = nicht erreichbar, nicht "ungültig"
+ if ((int)response.StatusCode >= 500)
+ return new KeyInfo(false, null, null, "HTTP " + (int)response.StatusCode) { Unreachable = true };
+ var info = await response.Content.ReadFromJsonAsync();
+ return info ?? new KeyInfo(false, null, null, "Empty response") { Unreachable = true };
+ }
+ catch (Exception e)
+ {
+ return new KeyInfo(false, null, null, e.Message) { Unreachable = true };
+ }
+ }
+
+ ///
+ /// Antwort auf den Heartbeat. Status: HTTP-Code, 0 = Master nicht erreichbar, 401 = Schlüssel ungültig.
+ /// Reachable: ob der Master den Server von außen erreicht (null = Prüfung läuft noch).
+ ///
+ public sealed record HeartbeatResult(int Status, bool? Reachable, bool Listed, string Address, int Port);
+ record HeartbeatReply(bool? Reachable, bool Listed, string Address, int Port);
+
+ /// Beim Beenden: sofort aus der Serverliste nehmen (sonst erst nach dem Heartbeat-Timeout).
+ public async Task UnregisterAsync(string key)
+ {
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
+ await http.PostAsJsonAsync(baseUrl + "/api/servers/unregister", new { key }, cts.Token);
+ }
+ catch { }
+ }
+
+ public async Task HeartbeatAsync(ServerConfig config, int players, string version)
+ {
+ try
+ {
+ var body = new HeartbeatBody(config.LicenseKey, config.Name, config.Description, config.PublicAddress, config.Port,
+ players, config.MaxPlayers, version, config.Public, config.RequireSteam);
+ var response = await http.PostAsJsonAsync(baseUrl + "/api/servers/heartbeat", body);
+ int status = (int)response.StatusCode;
+ if (!response.IsSuccessStatusCode) return new HeartbeatResult(status, null, false, null, 0);
+
+ HeartbeatReply reply = null;
+ try { reply = await response.Content.ReadFromJsonAsync(); } catch { }
+ return new HeartbeatResult(status, reply?.Reachable, reply?.Listed ?? false, reply?.Address, reply?.Port ?? 0);
+ }
+ catch
+ {
+ return new HeartbeatResult(0, null, false, null, 0);
+ }
+ }
+}
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..78f7f55
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,172 @@
+using System.Runtime.InteropServices;
+using MSCO.Server;
+
+// Datenordner (server.json, save/, world.json): Standard = Programmordner,
+// unter Linux z.B. "--data /var/lib/msco" oder per Umgebungsvariable MSCO_DATA
+string dataDir = ArgValue("--data") ?? Environment.GetEnvironmentVariable("MSCO_DATA") ?? AppContext.BaseDirectory;
+dataDir = Path.GetFullPath(dataDir);
+Directory.CreateDirectory(dataDir);
+string configPath = Path.Combine(dataDir, "server.json");
+string commandFile = Path.Combine(dataDir, "commands.in");
+
+// --cmd "": Befehl an den laufenden Server geben (z.B. docker exec msco-server msco op 1).
+// Der Server liest die Datei jede halbe Sekunde; die Antwort steht im Server-Log (docker logs).
+if (Array.IndexOf(args, "--cmd") >= 0)
+{
+ int at = Array.IndexOf(args, "--cmd");
+ string command = string.Join(" ", args.Skip(at + 1)).Trim();
+ if (command.Length == 0) { Console.WriteLine("Usage: msco (e.g. msco players, msco op 1, msco say hello)"); return 1; }
+ File.AppendAllText(commandFile, command + Environment.NewLine);
+ Console.WriteLine("Sent: " + command + " (output: docker logs --tail 20 msco-server)");
+ return 0;
+}
+
+try { Console.Title = "MSCO Server"; } catch { } // nicht jedes Terminal unterstützt einen Titel
+GameServer.Log("MSCO dedicated server " + GameServer.ModVersion + " (" + RuntimeInformation.OSDescription + ")");
+GameServer.Log("Data folder: " + dataDir);
+GameServer.Log("Master server: " + MasterServer.Url);
+
+var config = ServerConfig.LoadOrCreate(configPath, out bool created);
+var fromEnvironment = config.ApplyEnvironment();
+if (fromEnvironment.Count > 0)
+ GameServer.Log("Settings overridden by environment (.env): " + string.Join(", ", fromEnvironment));
+if (created && string.IsNullOrWhiteSpace(config.LicenseKey))
+{
+ GameServer.Log("Created " + configPath);
+ GameServer.Log("Get a license key at " + MasterServer.Url + "/keys and put it into server.json (\"LicenseKey\").");
+ Pause();
+ return 1;
+}
+
+// --import-save [Pfad]: Spielstand aus My Summer Car übernehmen
+if (Array.IndexOf(args, "--import-save") >= 0)
+{
+ string source = ArgValue("--import-save") ?? DefaultSaveFolder();
+ if (source == null || !Directory.Exists(source))
+ {
+ GameServer.Log("Save folder not found" + (source != null ? ": " + source : "") +
+ ". Use --import-save \"/path/to/My Summer Car\" (the folder containing defaultES2File.txt).");
+ Pause();
+ return 1;
+ }
+ string target = Path.Combine(dataDir, "save");
+ Directory.CreateDirectory(target);
+ int copied = 0;
+ foreach (string name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" })
+ {
+ string file = Path.Combine(source, name);
+ if (!File.Exists(file)) continue;
+ File.Copy(file, Path.Combine(target, name), true);
+ copied++;
+ }
+ GameServer.Log($"Imported {copied} save files from {source}");
+ // Neuer Spielstand = alte Änderungen passen nicht mehr dazu
+ string world = Path.Combine(dataDir, "world.json");
+ if (File.Exists(world)) File.Delete(world);
+}
+
+// Lizenzschlüssel prüfen (wie beim FiveM-Keymaster)
+if (string.IsNullOrWhiteSpace(config.LicenseKey))
+{
+ GameServer.Log("No license key set. Get one at " + MasterServer.Url + "/keys and put it into server.json.");
+ Pause();
+ return 1;
+}
+// Automatisch mit dem Master (msco.mbm.wtf) verbinden: ist er gerade nicht erreichbar,
+// wird es mit wachsendem Abstand (max. 60 s) weiter versucht, statt aufzugeben.
+var master = new MasterClient(MasterServer.Url);
+MasterClient.KeyInfo key;
+for (int attempt = 1; ; attempt++)
+{
+ GameServer.Log($"Checking license key at {MasterServer.Url} ...");
+ key = await master.ValidateKeyAsync(config.LicenseKey);
+ if (!key.Unreachable) break;
+ int wait = Math.Min(60, 5 * attempt);
+ GameServer.Log($"Master server not reachable ({key.Error}) - retrying in {wait} s (Ctrl+C to cancel)");
+ await Task.Delay(TimeSpan.FromSeconds(wait));
+}
+if (!key.Valid)
+{
+ GameServer.Log("License key invalid: " + (key.Error ?? "unknown key"));
+ GameServer.Log("Manage your keys at " + MasterServer.Url + "/keys");
+ Pause();
+ return 1;
+}
+GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})");
+
+var server = new GameServer(config, dataDir, configPath, key.Owner);
+if (!server.LoadSave())
+{
+ Pause();
+ return 1;
+}
+
+// Konsolenbefehle in eigenem Thread lesen, ausgeführt werden sie in der Server-Schleife.
+// Als Dienst (systemd/Docker ohne Terminal) gibt es keine Eingabe - dann einfach ohne Konsole laufen.
+var input = new Thread(() =>
+{
+ try
+ {
+ string line;
+ while ((line = Console.ReadLine()) != null) server.EnqueueCommand(line);
+ }
+ catch { }
+}) { IsBackground = true };
+input.Start();
+
+// Befehle von "msco " (docker exec) aus der Datei commands.in übernehmen
+var commandWatcher = new Thread(() =>
+{
+ string processing = commandFile + ".processing";
+ while (true)
+ {
+ Thread.Sleep(500);
+ try
+ {
+ if (!File.Exists(commandFile)) continue;
+ File.Move(commandFile, processing, true);
+ foreach (string line in File.ReadAllLines(processing))
+ if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); server.EnqueueCommand(line.Trim()); }
+ File.Delete(processing);
+ }
+ catch { }
+ }
+}) { IsBackground = true };
+commandWatcher.Start();
+
+// Sauber beenden (Weltzustand speichern) bei Strg+C, systemctl stop / docker stop (SIGTERM) und SIGQUIT
+Console.CancelKeyPress += (_, e) => { e.Cancel = true; server.EnqueueCommand("stop"); };
+using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; server.EnqueueCommand("stop"); });
+using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; server.EnqueueCommand("stop"); });
+
+server.Run();
+return 0;
+
+string ArgValue(string name)
+{
+ int i = Array.IndexOf(args, name);
+ return i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[i + 1] : null;
+}
+
+// Spielstand dieses Rechners: Windows direkt, Linux über Steam Proton
+static string DefaultSaveFolder()
+{
+ string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ const string relative = "AppData/LocalLow/Amistech/My Summer Car";
+ if (OperatingSystem.IsWindows()) return Path.Combine(home, relative);
+
+ foreach (string steamRoot in new[] { ".steam/steam", ".local/share/Steam", ".var/app/com.valvesoftware.Steam/.local/share/Steam" })
+ {
+ string proton = Path.Combine(home, steamRoot, "steamapps/compatdata/516750/pfx/drive_c/users/steamuser", relative);
+ if (Directory.Exists(proton)) return proton;
+ }
+ return null;
+}
+
+static void Pause()
+{
+ // In Docker nicht auf Enter warten: sonst sieht der Container wie "läuft" aus, obwohl der Server beendet ist
+ if (Console.IsInputRedirected || Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true") return;
+ Console.WriteLine("Press Enter to exit.");
+ Console.ReadLine();
+}
diff --git a/Protocol.cs b/Protocol.cs
new file mode 100644
index 0000000..c787696
--- /dev/null
+++ b/Protocol.cs
@@ -0,0 +1,175 @@
+using System.Net.Sockets;
+
+namespace MSCO.Server;
+
+/// Muss mit MsgType in der Mod (NetSession.cs) übereinstimmen.
+public enum MsgType : byte
+{
+ Hello = 1,
+ Welcome = 2,
+ Reject = 3,
+ Player = 4,
+ SaveBegin = 5,
+ SaveFile = 6,
+ StartGame = 7,
+ ObjStates = 8,
+ ObjOwn = 9,
+ ObjRelease = 10,
+ FsmEvent = 11,
+ PlayerJoined = 12,
+ PlayerLeft = 13,
+ VehicleFx = 14,
+ Ping = 15,
+ Ready = 16, // Client -> Server: Spiel geladen, Objekte erfasst
+ WorldSnapshot = 17,// Server -> Client: aktueller Weltzustand
+ Info = 18, // Server-Browser fragt Infos ab (ohne Hello)
+ InfoReply = 19,
+ WorldTime = 20, // Uhrzeit + Wetter vom "Weltuhr"-Spieler
+ MoneyDelta = 21, // Geld hat sich geändert (+/- Betrag, gemeinsame Kasse)
+ DoorState = 22, // Tür/Tor/Haube hat den Zustand gewechselt
+ AdminStatus = 23, // Server -> Client: bool, ist Admin
+ AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
+ AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin]
+ AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund
+ AdminResult = 27, // Server -> Client: string Meldung
+ Chat = 28, // Chatnachricht (string)
+ VarSync = 29 // Fahrzeugzustand: int n, je [int key, byte typ, wert]
+}
+
+public readonly record struct Message(MsgType Type, byte[] Payload)
+{
+ public BinaryReader Reader() => new(new MemoryStream(Payload));
+}
+
+public static class Wire
+{
+ public static byte[] Build(Action write)
+ {
+ using var ms = new MemoryStream();
+ using var w = new BinaryWriter(ms);
+ write(w);
+ w.Flush();
+ return ms.ToArray();
+ }
+
+ public static byte[] WithSender(int senderId, byte[] payload)
+ {
+ var result = new byte[4 + payload.Length];
+ BitConverter.GetBytes(senderId).CopyTo(result, 0);
+ Buffer.BlockCopy(payload, 0, result, 4, payload.Length);
+ return result;
+ }
+
+ public static byte[] Frame(MsgType type, byte[] payload)
+ {
+ payload ??= Array.Empty();
+ var frame = new byte[5 + payload.Length];
+ BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
+ frame[4] = (byte)type;
+ Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
+ return frame;
+ }
+}
+
+/// TCP-Verbindung zu einem Spieler, gleiches Format wie in der Mod: [int Länge][byte Typ][Payload].
+public sealed class Connection
+{
+ const int MaxFrame = 32 * 1024 * 1024;
+
+ public bool IsClosed { get; private set; }
+ public string CloseReason { get; private set; }
+ public string RemoteAddress { get; }
+ /// Wann zuletzt etwas ankam (Clients schicken alle 2 s einen Ping).
+ public DateTime LastReceiveUtc { get; private set; } = DateTime.UtcNow;
+
+ readonly TcpClient client;
+ readonly NetworkStream stream;
+ readonly byte[] readBuffer = new byte[64 * 1024];
+ byte[] buffer = new byte[64 * 1024];
+ int count;
+
+ public Connection(TcpClient client)
+ {
+ this.client = client;
+ client.NoDelay = true;
+ client.SendBufferSize = 4 * 1024 * 1024;
+ stream = client.GetStream();
+ RemoteAddress = client.Client.RemoteEndPoint?.ToString() ?? "?";
+ }
+
+ public List Poll()
+ {
+ var messages = new List();
+ if (IsClosed) return messages;
+ try
+ {
+ if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
+ {
+ Close("Connection closed");
+ return messages;
+ }
+ while (stream.DataAvailable)
+ {
+ int read = stream.Read(readBuffer, 0, readBuffer.Length);
+ if (read <= 0) break;
+ Append(readBuffer, read);
+ LastReceiveUtc = DateTime.UtcNow;
+ }
+ Parse(messages);
+ }
+ catch (Exception e)
+ {
+ Close("Network error: " + e.Message);
+ }
+ return messages;
+ }
+
+ public void Send(MsgType type, byte[] payload = null)
+ {
+ if (IsClosed) return;
+ try
+ {
+ byte[] frame = Wire.Frame(type, payload);
+ stream.Write(frame, 0, frame.Length);
+ }
+ catch (Exception e)
+ {
+ Close("Network error: " + e.Message);
+ }
+ }
+
+ public void Close(string reason)
+ {
+ if (IsClosed) return;
+ IsClosed = true;
+ CloseReason = reason;
+ try { client.Close(); } catch { }
+ }
+
+ void Append(byte[] data, int length)
+ {
+ if (count + length > buffer.Length) Array.Resize(ref buffer, Math.Max(count + length, buffer.Length * 2));
+ Buffer.BlockCopy(data, 0, buffer, count, length);
+ count += length;
+ }
+
+ void Parse(List messages)
+ {
+ int offset = 0;
+ while (count - offset >= 5)
+ {
+ int length = BitConverter.ToInt32(buffer, offset);
+ if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
+ if (count - offset - 4 < length) break;
+ var payload = new byte[length - 1];
+ Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
+ messages.Add(new Message((MsgType)buffer[offset + 4], payload));
+ offset += 4 + length;
+ }
+ if (offset > 0)
+ {
+ Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset);
+ count -= offset;
+ }
+ }
+}
diff --git a/README.md b/README.md
index 62435c4..bb38231 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,117 @@
-# MSCO-Dedicated-Server
+# MSCO Dedicated Server
+Dedizierter Server für **MSCO – My Summer Car Online**. Er hält einen gemeinsamen Spielstand, speichert alles,
+was die Spieler verändern, und gibt es an später Beitretende weiter. .NET 10, läuft unter Linux (Docker oder systemd)
+und Windows.
+
+- Mod: [MSCO](https://git.mbm.wtf/MrMaho/MSCO)
+- Keymaster & Serverliste: [MSCO-Keymaster](https://git.mbm.wtf/MrMaho/MSCO-Keymaster) – https://msco.mbm.wtf
+
+## Voraussetzungen
+
+- Ein **Lizenzschlüssel** von https://msco.mbm.wtf/keys (Login mit Steam, wie der FiveM-Keymaster).
+ Der Server prüft ihn beim Start und meldet sich danach regelmäßig beim Master für die Serverliste.
+- Ein My-Summer-Car-Spielstand als Startwelt.
+- Port **7777/TCP** im Router/Firewall freigegeben (sonst wird der Server nicht gelistet).
+- Server und Mod müssen dieselbe Version haben (sonst bekommen Spieler eine Warnung).
+
+## Mit Docker (empfohlen)
+
+```bash
+git clone https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server.git
+cd MSCO-Dedicated-Server
+docker compose up -d --build # erster Start legt data/server.json an und beendet sich
+```
+
+1. In `data/server.json` mindestens `LicenseKey` eintragen.
+2. Die Spielstand-Dateien nach `data/save/` kopieren (siehe unten).
+3. `docker compose up -d`
+
+| Aktion | Befehl |
+|---|---|
+| Konsole | `docker attach msco-server` – verlassen mit **Strg+P, dann Strg+Q** (Strg+C beendet den Server) |
+| Einzelner Befehl | `docker exec msco-server msco `, z.B. `msco players` |
+| Logs | `docker logs -f msco-server` |
+| Stoppen | `docker compose stop` – speichert vorher den Weltzustand und meldet den Server ab |
+| Update | `git pull && docker compose up -d --build` |
+
+Anderer Port: in `data/server.json` **und** in `docker-compose.yml` ändern.
+
+## Ohne Docker
+
+**Linux (systemd):** .NET 10 Runtime installieren (`sudo apt install dotnet-runtime-10.0`), dann
+`dotnet publish -c Release -o /opt/msco-server` und [`deploy/msco-server.service`](deploy/msco-server.service)
+einrichten (Anleitung oben in der Datei). Daten liegen in `/var/lib/msco-server`.
+Für eine Konsole den Server stattdessen in `screen`/`tmux` starten.
+
+**Windows:** `dotnet publish -c Release -o publish`, dann `publish\MSCO.Server.exe` starten.
+
+Startparameter:
+- `--data ` – Datenordner (Standard: Programmordner; Docker: `/data`)
+- `--import-save [pfad]` – Spielstand übernehmen (ohne Pfad: der eigene Spielstand dieses PCs, auch Proton)
+- `--cmd ` – Befehl an einen laufenden Server im selben Datenordner schicken
+
+## Spielstand
+
+In `data/save/` gehören diese Dateien aus dem My-Summer-Car-Spielstand:
+`defaultES2File.txt`, `items.txt`, `meshsave.txt`, `graveyard.txt` (und `notepad.txt`, falls vorhanden).
+
+- Windows: `%USERPROFILE%\AppData\LocalLow\Amistech\My Summer Car`
+- Linux (Proton): `~/.steam/steam/steamapps/compatdata/516750/pfx/drive_c/users/steamuser/AppData/LocalLow/Amistech/My Summer Car`
+
+Neuen Spielstand übernehmen: Dateien ersetzen, `data/world.json` löschen, Server neu starten.
+
+## Einstellungen (`data/server.json`)
+
+| Einstellung | Bedeutung |
+|---|---|
+| `LicenseKey` | Schlüssel von der Keymaster-Seite (Pflicht) |
+| `Name`, `Description` | Anzeige im Server-Browser |
+| `MaxPlayers`, `Port` | Spieler-Limit (max. 3 sinnvoll), Port |
+| `Public` | `false` = nicht in der Liste, nur Direct Connect |
+| `RequireSteam` | `true` = nur Spieler mit Steam; `false` = Non-Steam-Server (Spieler werden über den Namen erkannt) |
+| `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) |
+| `AutosaveSeconds` | wie oft der Weltzustand gespeichert wird |
+| `SteamWebApiKey` | optional: Steam-Anmeldetickets der Spieler bei Steam prüfen |
+| `BannedSteamIds` | gesperrte Spieler |
+| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen |
+
+Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvariablen (`MSCO_LICENSE_KEY`,
+`MSCO_NAME`, `MSCO_PORT`, `MSCO_MAX_PLAYERS`, `MSCO_PUBLIC`, `MSCO_REQUIRE_STEAM`, `MSCO_PUBLIC_ADDRESS`,
+`MSCO_STEAM_WEB_API_KEY`) überschreiben `server.json`.
+
+## Konsolenbefehle
+
+| Befehl | Beschreibung |
+|---|---|
+| `status`, `players` | Übersicht, Spielerliste mit IDs |
+| `kick [grund]`, `ban [grund]` | Spieler entfernen / sperren |
+| `say ` | Chatnachricht an alle |
+| `admins`, `op `, `deop <...>` | Admins anzeigen / geben / nehmen |
+| `save` | Weltzustand jetzt speichern |
+| `reset-world` | alle Änderungen seit dem Spielstand verwerfen |
+| `reload-save` | Spielstand-Dateien neu einlesen |
+| `stop` | speichern und beenden |
+
+Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F8-Konsole) ein Kick/Ban-Menü.
+Der Chat landet auch im Server-Log.
+
+## Was der Server speichert
+
+- `save/` – der Ausgangs-Spielstand, den jeder neue Spieler bekommt.
+- `world.json` – alles, was seitdem passiert ist: Positionen bewegter Objekte, Schraub-/Montage-Aktionen,
+ Geld, Türen/Tore, Fahrzeugzustand (Tank, Öl, Schrauben, Verschleiß …) und die letzte Position jedes Spielers.
+
+Beim Beitreten: Spielstand laden → Objekte erfassen → Weltzustand anwenden → an der letzten eigenen Position weiterspielen.
+
+## Sicherheit
+
+- Die Adresse des Masters (`https://msco.mbm.wtf`) ist fest eingebaut, damit niemand die Schlüsselprüfung mit
+ einem eigenen Master umgehen kann. Nur Debug-Builds akzeptieren `MSCO_MASTER_URL`.
+- `data/` enthält Lizenzschlüssel und API-Keys – nicht veröffentlichen (steht in `.gitignore`).
+
+## Lizenz
+
+GNU AGPL-3.0 – siehe [LICENSE](LICENSE).
+
+Made by **MBM** – [mbm.wtf](https://mbm.wtf)
diff --git a/ServerConfig.cs b/ServerConfig.cs
new file mode 100644
index 0000000..39c2bac
--- /dev/null
+++ b/ServerConfig.cs
@@ -0,0 +1,84 @@
+using System.Text.Json;
+
+namespace MSCO.Server;
+
+/// server.json - wird beim ersten Start mit Standardwerten angelegt.
+public sealed class ServerConfig
+{
+ public string LicenseKey { get; set; } = "";
+ public string Name { get; set; } = "My MSCO Server";
+ public string Description { get; set; } = "";
+ public int Port { get; set; } = 7777;
+ ///
+ /// Adresse, unter der Spieler den Server erreichen (IP oder Domain). Leer = die IP, von der
+ /// sich der Server beim Master meldet. Nötig z.B. wenn Master und Server im selben Docker-Netz laufen.
+ ///
+ public string PublicAddress { get; set; } = "";
+ public int MaxPlayers { get; set; } = 8;
+ /// In der Serverliste anzeigen.
+ public bool Public { get; set; } = true;
+ /// Nur Spieler mit Steam zulassen (false = auch ohne Steam, "Non-Steam"-Server).
+ public bool RequireSteam { get; set; } = true;
+ /// Weltzustand so oft auf die Festplatte schreiben.
+ public int AutosaveSeconds { get; set; } = 60;
+ /// Optional: Steam Web API Key, um Steam-Tickets der Spieler bei Steam zu prüfen.
+ public string SteamWebApiKey { get; set; } = "";
+ /// Gesperrte SteamID64s.
+ public List BannedSteamIds { get; set; } = new();
+ /// Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.
+ public List Admins { get; set; } = new();
+
+ static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
+
+ public static ServerConfig LoadOrCreate(string path, out bool created)
+ {
+ created = !File.Exists(path);
+ if (created)
+ {
+ var fresh = new ServerConfig();
+ fresh.Save(path);
+ return fresh;
+ }
+ var config = JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? new ServerConfig();
+ // Neue Einstellungen (z.B. Admins) auch in ältere server.json schreiben, damit man sie sieht
+ try { config.Save(path); } catch (Exception) { } // z.B. schreibgeschützt - dann eben nicht
+ return config;
+ }
+
+ public void Save(string path) => File.WriteAllText(path, JsonSerializer.Serialize(this, Options));
+
+ ///
+ /// Umgebungsvariablen überschreiben server.json (praktisch für Docker/systemd):
+ /// MSCO_LICENSE_KEY, MSCO_NAME, MSCO_DESCRIPTION, MSCO_PORT, MSCO_MAX_PLAYERS, MSCO_PUBLIC,
+ /// MSCO_PUBLIC_ADDRESS, MSCO_STEAM_WEB_API_KEY (die Master-Adresse ist fest, siehe MasterServer)
+ ///
+ /// Namen der gesetzten Umgebungsvariablen (fürs Log, damit klar ist, woher ein Wert kommt).
+ public List ApplyEnvironment()
+ {
+ var used = new List();
+ string Take(string name, string current)
+ {
+ string value = Env(name);
+ if (value == null) return current;
+ used.Add(name);
+ return value;
+ }
+
+ LicenseKey = Take("MSCO_LICENSE_KEY", LicenseKey);
+ Name = Take("MSCO_NAME", Name);
+ Description = Take("MSCO_DESCRIPTION", Description);
+ PublicAddress = Take("MSCO_PUBLIC_ADDRESS", PublicAddress);
+ SteamWebApiKey = Take("MSCO_STEAM_WEB_API_KEY", SteamWebApiKey);
+ if (int.TryParse(Env("MSCO_PORT"), out int port)) { Port = port; used.Add("MSCO_PORT"); }
+ if (int.TryParse(Env("MSCO_MAX_PLAYERS"), out int max)) { MaxPlayers = max; used.Add("MSCO_MAX_PLAYERS"); }
+ if (bool.TryParse(Env("MSCO_PUBLIC"), out bool isPublic)) { Public = isPublic; used.Add("MSCO_PUBLIC"); }
+ if (bool.TryParse(Env("MSCO_REQUIRE_STEAM"), out bool requireSteam)) { RequireSteam = requireSteam; used.Add("MSCO_REQUIRE_STEAM"); }
+ return used;
+ }
+
+ static string Env(string name)
+ {
+ string value = Environment.GetEnvironmentVariable(name);
+ return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+}
diff --git a/SteamVerifier.cs b/SteamVerifier.cs
new file mode 100644
index 0000000..7bf5b31
--- /dev/null
+++ b/SteamVerifier.cs
@@ -0,0 +1,57 @@
+using System.Text.Json;
+
+namespace MSCO.Server;
+
+///
+/// Prüft Steam-Anmeldetickets der Spieler über die Steam Web API
+/// (ISteamUserAuth/AuthenticateUserTicket), sofern in server.json ein Key eingetragen ist.
+/// Ohne Key wird nur geprüft, dass der Client eine Steam-ID und ein Ticket mitschickt.
+///
+public sealed class SteamVerifier
+{
+ const int MySummerCarAppId = 516750;
+
+ readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) };
+ readonly string apiKey;
+
+ public bool Enabled => !string.IsNullOrWhiteSpace(apiKey);
+
+ public SteamVerifier(string apiKey)
+ {
+ this.apiKey = apiKey;
+ }
+
+ /// Gibt null zurück, wenn alles passt, sonst den Ablehnungsgrund.
+ public async Task VerifyAsync(ulong claimedSteamId, byte[] ticket, bool requireSteam)
+ {
+ bool hasSteam = claimedSteamId != 0 && ticket != null && ticket.Length > 0;
+ if (!hasSteam) return requireSteam ? "Steam is required to join this server" : null; // Non-Steam-Server
+ if (!Enabled) return null;
+
+ string url = "https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=" + apiKey +
+ "&appid=" + MySummerCarAppId + "&ticket=" + Convert.ToHexString(ticket);
+ try
+ {
+ // Frisch erzeugte Tickets sind manchmal erst nach einem Moment gültig
+ for (int attempt = 0; attempt < 3; attempt++)
+ {
+ using var doc = JsonDocument.Parse(await http.GetStringAsync(url));
+ var response = doc.RootElement.GetProperty("response");
+ if (response.TryGetProperty("params", out var p))
+ {
+ string steamId = p.GetProperty("steamid").GetString();
+ if (steamId != claimedSteamId.ToString()) return "Steam ticket does not match your account";
+ if (p.TryGetProperty("vacbanned", out var vac) && vac.GetBoolean()) return "VAC banned";
+ return null;
+ }
+ await Task.Delay(1000);
+ }
+ return "Steam could not verify your ticket";
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine("[steam] verification failed: " + e.Message);
+ return "Steam verification failed, try again";
+ }
+ }
+}
diff --git a/WorldState.cs b/WorldState.cs
new file mode 100644
index 0000000..8d6cdc8
--- /dev/null
+++ b/WorldState.cs
@@ -0,0 +1,185 @@
+using System.Text.Json;
+
+namespace MSCO.Server;
+
+///
+/// Was sich seit dem Ausgangs-Spielstand (Ordner "save") geändert hat:
+/// Positionen aller bewegten Objekte, alle Schraub-/Montage-Aktionen (in Reihenfolge)
+/// und wo jeder Spieler zuletzt war. Neue Spieler laden den Spielstand und bekommen
+/// danach diesen Zustand als Snapshot.
+///
+public sealed class WorldState
+{
+ public sealed class ObjectPose
+ {
+ public float[] P { get; set; } = new float[3];
+ public float[] R { get; set; } = new float[4];
+ }
+
+ public Dictionary Objects { get; set; } = new();
+ /// FsmEvent-Payloads (Base64) in der Reihenfolge, in der sie passiert sind.
+ public List Events { get; set; } = new();
+ /// SteamID64 -> letzte Position.
+ public Dictionary PlayerPositions { get; set; } = new();
+ /// Summe aller Geldänderungen seit dem Ausgangs-Spielstand (gemeinsame Kasse).
+ public float MoneyDelta { get; set; }
+ /// Letzter Zustand jeder Tür/jedes Tors (FSM-Schlüssel -> Zustand).
+ public Dictionary Doors { get; set; } = new();
+ /// Fahrzeugzustand (Tank, Öl, Schrauben ...): Schlüssel -> Base64 [byte typ, wert].
+ public Dictionary Vars { get; set; } = new();
+
+ public bool Dirty { get; private set; }
+
+ static readonly JsonSerializerOptions Options = new() { WriteIndented = false };
+
+ public static WorldState Load(string path)
+ {
+ if (!File.Exists(path)) return new WorldState();
+ return JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? new WorldState();
+ }
+
+ public void Save(string path)
+ {
+ string tmp = path + ".tmp";
+ File.WriteAllText(tmp, JsonSerializer.Serialize(this, Options));
+ File.Move(tmp, path, true);
+ Dirty = false;
+ }
+
+ public void Reset()
+ {
+ Objects.Clear();
+ Events.Clear();
+ PlayerPositions.Clear();
+ MoneyDelta = 0f;
+ Doors.Clear();
+ Vars.Clear();
+ Dirty = true;
+ }
+
+ // ---------------------------------------------------------------- Aus Spiel-Nachrichten lernen
+
+ /// ObjStates: int Anzahl, dann je [int id, 3 float pos, 4 float rot, 3 float vel].
+ public void ApplyObjStates(byte[] payload)
+ {
+ using var r = new BinaryReader(new MemoryStream(payload));
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++) ReadPose(r);
+ Dirty = true;
+ }
+
+ /// ObjRelease: ein Eintrag wie bei ObjStates (Endposition).
+ public void ApplyObjRelease(byte[] payload)
+ {
+ using var r = new BinaryReader(new MemoryStream(payload));
+ ReadPose(r);
+ Dirty = true;
+ }
+
+ void ReadPose(BinaryReader r)
+ {
+ int id = r.ReadInt32();
+ if (!Objects.TryGetValue(id, out var pose)) Objects[id] = pose = new ObjectPose();
+ for (int k = 0; k < 3; k++) pose.P[k] = r.ReadSingle();
+ for (int k = 0; k < 4; k++) pose.R[k] = r.ReadSingle();
+ r.ReadSingle(); r.ReadSingle(); r.ReadSingle(); // Geschwindigkeit wird nicht gespeichert
+ }
+
+ public void ApplyMoney(byte[] payload)
+ {
+ if (payload.Length < 4) return;
+ MoneyDelta += BitConverter.ToSingle(payload, 0);
+ Dirty = true;
+ }
+
+ public void ApplyDoor(byte[] payload)
+ {
+ using var r = new BinaryReader(new MemoryStream(payload));
+ string key = r.ReadString();
+ Doors[key] = r.ReadString();
+ Dirty = true;
+ }
+
+ public void ApplyVars(byte[] payload)
+ {
+ using var r = new BinaryReader(new MemoryStream(payload));
+ int count = r.ReadInt32();
+ if (count < 0 || count > 10000) return;
+ for (int i = 0; i < count; i++)
+ {
+ int key = r.ReadInt32();
+ byte type = r.ReadByte();
+ byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
+ var all = new byte[value.Length + 1];
+ all[0] = type;
+ value.CopyTo(all, 1);
+ Vars[key] = Convert.ToBase64String(all);
+ }
+ Dirty = true;
+ }
+
+ public void AddFsmEvent(byte[] payload)
+ {
+ Events.Add(Convert.ToBase64String(payload));
+ Dirty = true;
+ }
+
+ /// Player: beginnt mit der Position (3 float).
+ public void ApplyPlayer(string steamId, byte[] payload)
+ {
+ if (string.IsNullOrEmpty(steamId) || payload.Length < 12) return;
+ PlayerPositions[steamId] = new[]
+ {
+ BitConverter.ToSingle(payload, 0), BitConverter.ToSingle(payload, 4), BitConverter.ToSingle(payload, 8)
+ };
+ Dirty = true;
+ }
+
+ // ---------------------------------------------------------------- Snapshot für neue Spieler
+
+ ///
+ /// WorldSnapshot: bool hatSpawn [3 float], int Objekte, je [int id, 3 float pos, 4 float rot],
+ /// int Events, je [int Länge, bytes FsmEvent-Payload], float Geldänderung seit dem Spielstand.
+ ///
+ public byte[] BuildSnapshot(string steamId)
+ {
+ return Wire.Build(w =>
+ {
+ bool hasSpawn = steamId != null && PlayerPositions.TryGetValue(steamId, out _);
+ w.Write(hasSpawn);
+ if (hasSpawn)
+ foreach (float f in PlayerPositions[steamId]) w.Write(f);
+
+ w.Write(Objects.Count);
+ foreach (var kv in Objects)
+ {
+ w.Write(kv.Key);
+ foreach (float f in kv.Value.P) w.Write(f);
+ foreach (float f in kv.Value.R) w.Write(f);
+ }
+
+ w.Write(Events.Count);
+ foreach (string e in Events)
+ {
+ byte[] bytes = Convert.FromBase64String(e);
+ w.Write(bytes.Length);
+ w.Write(bytes);
+ }
+
+ // Geld: Summe der Änderungen seit dem Spielstand
+ w.Write(MoneyDelta);
+
+ // Türen/Tore: letzter Zustand
+ w.Write(Doors.Count);
+ foreach (var kv in Doors) { w.Write(kv.Key); w.Write(kv.Value); }
+
+ // Fahrzeugzustand: int n, je [int key, byte typ, wert]
+ w.Write(Vars.Count);
+ foreach (var kv in Vars)
+ {
+ w.Write(kv.Key);
+ w.Write(Convert.FromBase64String(kv.Value));
+ }
+ });
+ }
+}
diff --git a/deploy/msco-server.service b/deploy/msco-server.service
new file mode 100644
index 0000000..225cb0b
--- /dev/null
+++ b/deploy/msco-server.service
@@ -0,0 +1,27 @@
+# MSCO dedizierter Server als systemd-Dienst
+# Installation:
+# sudo useradd --system --home /var/lib/msco-server --create-home msco
+# dotnet publish -c Release -o /opt/msco-server
+# sudo cp deploy/msco-server.service /etc/systemd/system/
+# sudo systemctl daemon-reload && sudo systemctl enable --now msco-server
+# Logs: journalctl -u msco-server -f
+
+[Unit]
+Description=MSCO dedicated server (My Summer Car Online)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+User=msco
+WorkingDirectory=/opt/msco-server
+ExecStart=/usr/bin/dotnet /opt/msco-server/MSCO.Server.dll --data /var/lib/msco-server
+# Beim Stoppen speichert der Server den Weltzustand (SIGTERM)
+KillSignal=SIGTERM
+TimeoutStopSec=30
+Restart=on-failure
+RestartSec=10
+# Exit-Code 1 = Konfigurationsfehler (kein/ungültiger Schlüssel, kein Spielstand): nicht endlos neu starten
+RestartPreventExitStatus=1
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e288473
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,24 @@
+# MSCO dedizierter Gameserver mit Docker.
+#
+# docker compose up -d --build # erster Start legt data/server.json an und beendet sich
+# -> in data/server.json den LicenseKey (von https://msco.mbm.wtf/keys), Name, MaxPlayers ... eintragen
+# -> Spielstand-Dateien von My Summer Car nach data/save/ kopieren
+# docker compose up -d
+#
+# Konsole: docker attach msco-server (verlassen mit Strg+P, dann Strg+Q - NICHT Strg+C)
+# Befehl: docker exec msco-server msco (z.B. msco players, msco op )
+# Logs: docker logs -f msco-server
+# Stoppen: docker compose stop (speichert den Weltzustand)
+
+services:
+ server:
+ build: .
+ container_name: msco-server
+ restart: "on-failure:3" # bei fehlendem Schlüssel/Spielstand nicht endlos neu starten
+ stdin_open: true # Server-Konsole per docker attach
+ tty: true
+ stop_grace_period: 30s
+ ports:
+ - "7777:7777/tcp"
+ volumes:
+ - ./data:/data
diff --git a/logo.ico b/logo.ico
new file mode 100644
index 0000000..7d5f9b3
Binary files /dev/null and b/logo.ico differ