MSCO dedicated server 2.6: code, Docker, systemd, README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
data/
|
||||||
|
publish/
|
||||||
|
.git/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
data/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
publish/
|
||||||
+21
@@ -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
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user