MSCO dedicated server 2.6: code, Docker, systemd, README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 19:45:02 +02:00
co-authored by Claude Opus 5.5
parent 6df581ca7d
commit 0d289879f4
15 changed files with 1512 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
bin/
obj/
data/
publish/
.git/
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
data/
*.user
.vs/
publish/
+21
View File
@@ -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"]
+524
View File
@@ -0,0 +1,524 @@
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.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<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;
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<KeyValuePair<string, byte[]>>();
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<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();
}
}
/// <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 (now >= nextHeartbeat && heartbeat == null)
{
nextHeartbeat = now + HeartbeatSeconds;
heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
}
}
/// <summary>Erreichbarkeit laut Master nur bei Änderung ausgeben.</summary>
void ReportReachability(MasterClient.HeartbeatResult r)
{
// Prüfung läuft noch: in ein paar Sekunden nachfragen statt erst in 30 s
if (r.Reachable == null) nextHeartbeat = Math.Min(nextHeartbeat, clock.Elapsed.TotalSeconds + 6);
if (r.Reachable == null || r.Reachable == lastReachable) return;
lastReachable = r.Reachable;
if (r.Reachable == true)
Log($"Reachable from the internet at {r.Address}:{r.Port}" + (r.Listed ? " - listed in the server browser" : " (not public)"));
else
Log($"NOT reachable from the internet at {r.Address}:{r.Port} - not listed! Forward TCP port {r.Port} " +
"in your router/firewall, or set PublicAddress / MSCO_PUBLIC_ADDRESS if the address is wrong.");
}
// ---------------------------------------------------------------- Nachrichten
static bool IsGameMessage(MsgType t) =>
t is MsgType.Player or MsgType.ObjStates or MsgType.ObjOwn or MsgType.ObjRelease or MsgType.FsmEvent
or MsgType.VehicleFx or MsgType.WorldTime or MsgType.MoneyDelta or MsgType.DoorState or MsgType.Chat or MsgType.VarSync;
/// <summary>Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients).</summary>
int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min();
void Handle(Client c, Message m)
{
switch (m.Type)
{
case MsgType.Info:
// Server-Browser: Infos + Ping, danach Verbindung schließen
c.Conn.Send(MsgType.InfoReply, Wire.Build(w =>
{
w.Write(config.Name);
w.Write(config.Description ?? "");
w.Write(JoinedCount);
w.Write(config.MaxPlayers);
w.Write(ModVersion);
// Erweiterung für das Server-Profil im Browser (ältere Mods lesen hier nicht weiter)
w.Write(config.RequireSteam);
w.Write(ownerName ?? "");
w.Write((int)clock.Elapsed.TotalSeconds);
var names = clients.Values.Where(x => x.State == ClientState.Joined).Select(x => x.Name).ToList();
w.Write(names.Count);
foreach (string n in names) w.Write(n);
}));
c.Conn.Close("Info query");
return;
case MsgType.Hello:
if (c.State == ClientState.New) BeginJoin(c, m.Reader());
return;
case MsgType.Ready:
if (c.State != ClientState.Joined) return;
c.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}");
}
/// <summary>Spieler kicken oder bannen (Konsole oder Admin im Spiel); alle sehen es im Chat.</summary>
string KickOrBan(Client target, bool ban, string reason, string by)
{
reason = (reason ?? "").Trim();
if (reason.Length > 100) reason = reason[..100];
if (ban && !config.BannedSteamIds.Contains(target.SteamId))
{
config.BannedSteamIds.Add(target.SteamId);
config.Save(configPath);
}
string what = ban ? "banned" : "kicked";
ServerChat($"{target.Name} was {what}" + (reason.Length > 0 ? $" ({reason})" : ""));
Reject(target, $"You were {what} by {by}" + (reason.Length > 0 ? $": {reason}" : ""));
Log($"{target.Name} was {what} by {by}" + (reason.Length > 0 ? $": {reason}" : ""));
return $"{target.Name} was {what}";
}
/// <summary>Chatnachricht vom Server an alle (Absender-ID 0 = "Server").</summary>
void ServerChat(string text)
{
Broadcast(MsgType.Chat, Wire.WithSender(ServerId, Wire.Build(w => w.Write(text))));
}
void Broadcast(MsgType type, byte[] payload, int except = -1)
{
foreach (var c in clients.Values)
if (c.Id != except && c.State == ClientState.Joined) c.Conn.Send(type, payload);
}
// ---------------------------------------------------------------- Konsole
void RunCommands()
{
while (commands.TryDequeue(out string line))
{
// BOM am Zeilenanfang (z.B. bei umgeleiteter Eingabe) ignorieren
string[] args = line.Trim().TrimStart('').Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0) continue;
switch (args[0].ToLowerInvariant())
{
case "help":
Log("status | players | kick <id> [reason] | ban <id> [reason] | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
break;
case "say":
if (args.Length < 2) { Log("say <text>"); break; }
ServerChat(args[1]);
Log("[chat] Server: " + args[1]);
break;
case "admins":
Log("Admins: " + (config.Admins.Count == 0 ? "(none)" : string.Join(", ", config.Admins)));
break;
case "op":
case "deop":
{
bool makeAdmin = args[0] == "op";
string target = args.Length > 1 ? args[1].Trim() : "";
// Direkt eine SteamID64 (17 Ziffern) - geht auch, wenn der Spieler gerade nicht online ist
if (target.Length == 17 && target.All(char.IsDigit))
{
config.Admins.Remove(target);
if (makeAdmin) config.Admins.Add(target);
config.Save(configPath);
foreach (var online in clients.Values.Where(x => x.SteamId == target))
{
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}");
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MSCO.Server</RootNamespace>
<AssemblyName>MSCO.Server</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Symbol der .exe (Windows) -->
<ApplicationIcon>logo.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+101
View File
@@ -0,0 +1,101 @@
using System.Net.Http.Json;
namespace MSCO.Server;
/// <summary>
/// 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.
/// </summary>
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
}
/// <summary>Spricht mit dem Master-Server: Lizenzschlüssel prüfen und Server in der Liste melden.</summary>
public sealed class MasterClient
{
public sealed record KeyInfo(bool Valid, string Owner, string Label, string Error)
{
/// <summary>Master war nicht erreichbar (Netzwerk, Proxy-Fehler) - dann später erneut versuchen.</summary>
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<KeyInfo> 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<KeyInfo>();
return info ?? new KeyInfo(false, null, null, "Empty response") { Unreachable = true };
}
catch (Exception e)
{
return new KeyInfo(false, null, null, e.Message) { Unreachable = true };
}
}
/// <summary>
/// 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).
/// </summary>
public sealed record HeartbeatResult(int Status, bool? Reachable, bool Listed, string Address, int Port);
record HeartbeatReply(bool? Reachable, bool Listed, string Address, int Port);
/// <summary>Beim Beenden: sofort aus der Serverliste nehmen (sonst erst nach dem Heartbeat-Timeout).</summary>
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<HeartbeatResult> 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<HeartbeatReply>(); } catch { }
return new HeartbeatResult(status, reply?.Reachable, reply?.Listed ?? false, reply?.Address, reply?.Port ?? 0);
}
catch
{
return new HeartbeatResult(0, null, false, null, 0);
}
}
}
+172
View File
@@ -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>": 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 <command> (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 <befehl>" (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();
}
+175
View File
@@ -0,0 +1,175 @@
using System.Net.Sockets;
namespace MSCO.Server;
/// <summary>Muss mit MsgType in der Mod (NetSession.cs) übereinstimmen.</summary>
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<BinaryWriter> 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<byte>();
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;
}
}
/// <summary>TCP-Verbindung zu einem Spieler, gleiches Format wie in der Mod: [int Länge][byte Typ][Payload].</summary>
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; }
/// <summary>Wann zuletzt etwas ankam (Clients schicken alle 2 s einen Ping).</summary>
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<Message> Poll()
{
var messages = new List<Message>();
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<Message> 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;
}
}
}
+116 -1
View File
@@ -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 <befehl>`, 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 <ordner>` – Datenordner (Standard: Programmordner; Docker: `/data`)
- `--import-save [pfad]` – Spielstand übernehmen (ohne Pfad: der eigene Spielstand dieses PCs, auch Proton)
- `--cmd <befehl>` – 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 <id> [grund]`, `ban <id> [grund]` | Spieler entfernen / sperren |
| `say <text>` | Chatnachricht an alle |
| `admins`, `op <id oder SteamID64>`, `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)
+84
View File
@@ -0,0 +1,84 @@
using System.Text.Json;
namespace MSCO.Server;
/// <summary>server.json - wird beim ersten Start mit Standardwerten angelegt.</summary>
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;
/// <summary>
/// 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.
/// </summary>
public string PublicAddress { get; set; } = "";
public int MaxPlayers { get; set; } = 8;
/// <summary>In der Serverliste anzeigen.</summary>
public bool Public { get; set; } = true;
/// <summary>Nur Spieler mit Steam zulassen (false = auch ohne Steam, "Non-Steam"-Server).</summary>
public bool RequireSteam { get; set; } = true;
/// <summary>Weltzustand so oft auf die Festplatte schreiben.</summary>
public int AutosaveSeconds { get; set; } = 60;
/// <summary>Optional: Steam Web API Key, um Steam-Tickets der Spieler bei Steam zu prüfen.</summary>
public string SteamWebApiKey { get; set; } = "";
/// <summary>Gesperrte SteamID64s.</summary>
public List<string> BannedSteamIds { get; set; } = new();
/// <summary>Admins (SteamID64): dürfen im Spiel mit /admin Spieler kicken und bannen.</summary>
public List<string> 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<ServerConfig>(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));
/// <summary>
/// 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)
/// </summary>
/// <returns>Namen der gesetzten Umgebungsvariablen (fürs Log, damit klar ist, woher ein Wert kommt).</returns>
public List<string> ApplyEnvironment()
{
var used = new List<string>();
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();
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text.Json;
namespace MSCO.Server;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Gibt null zurück, wenn alles passt, sonst den Ablehnungsgrund.</summary>
public async Task<string> 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";
}
}
}
+185
View File
@@ -0,0 +1,185 @@
using System.Text.Json;
namespace MSCO.Server;
/// <summary>
/// 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.
/// </summary>
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<int, ObjectPose> Objects { get; set; } = new();
/// <summary>FsmEvent-Payloads (Base64) in der Reihenfolge, in der sie passiert sind.</summary>
public List<string> Events { get; set; } = new();
/// <summary>SteamID64 -> letzte Position.</summary>
public Dictionary<string, float[]> PlayerPositions { get; set; } = new();
/// <summary>Summe aller Geldänderungen seit dem Ausgangs-Spielstand (gemeinsame Kasse).</summary>
public float MoneyDelta { get; set; }
/// <summary>Letzter Zustand jeder Tür/jedes Tors (FSM-Schlüssel -> Zustand).</summary>
public Dictionary<string, string> Doors { get; set; } = new();
/// <summary>Fahrzeugzustand (Tank, Öl, Schrauben ...): Schlüssel -> Base64 [byte typ, wert].</summary>
public Dictionary<int, string> 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<WorldState>(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
/// <summary>ObjStates: int Anzahl, dann je [int id, 3 float pos, 4 float rot, 3 float vel].</summary>
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;
}
/// <summary>ObjRelease: ein Eintrag wie bei ObjStates (Endposition).</summary>
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;
}
/// <summary>Player: beginnt mit der Position (3 float).</summary>
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
/// <summary>
/// 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.
/// </summary>
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));
}
});
}
}
+27
View File
@@ -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
+24
View File
@@ -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 <befehl> (z.B. msco players, msco op <SteamID64>)
# 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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB