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