MSCO dedicated server 2.6: code, Docker, systemd, README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+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();
|
||||
}
|
||||
Reference in New Issue
Block a user