Panel independent of the game server: ServerHost owns port + panel and starts/stops the game server; panel works while stopped/crashed/unconfigured (settings, save ZIP upload, backups, bans offline); crash auto-restart; players told 'offline'; tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+97
-37
@@ -38,7 +38,8 @@ public sealed class AdminPanel
|
|||||||
public DateTime Expires;
|
public DateTime Expires;
|
||||||
}
|
}
|
||||||
|
|
||||||
GameServer server; // nach einem Neustart aus dem Panel ein neuer (Attach)
|
readonly ServerHost host; // läuft immer - der Gameserver darunter kann aus sein
|
||||||
|
readonly string dataDir;
|
||||||
ServerConfig config;
|
ServerConfig config;
|
||||||
readonly PanelUsers users;
|
readonly PanelUsers users;
|
||||||
readonly ConcurrentDictionary<string, Session> sessions = new();
|
readonly ConcurrentDictionary<string, Session> sessions = new();
|
||||||
@@ -53,18 +54,19 @@ public sealed class AdminPanel
|
|||||||
public int InternalPort { get; private set; }
|
public int InternalPort { get; private set; }
|
||||||
public bool SharesGamePort { get; private set; }
|
public bool SharesGamePort { get; private set; }
|
||||||
|
|
||||||
AdminPanel(GameServer server, ServerConfig config, string dataDir)
|
AdminPanel(ServerHost host, ServerConfig config, string dataDir)
|
||||||
{
|
{
|
||||||
this.server = server;
|
this.host = host;
|
||||||
|
this.dataDir = dataDir;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
users = new PanelUsers(Path.Combine(dataDir, "panel_users.json"));
|
users = new PanelUsers(Path.Combine(dataDir, "panel_users.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Start
|
// ---------------------------------------------------------------- Start
|
||||||
|
|
||||||
public static AdminPanel Start(GameServer server, ServerConfig config, string dataDir)
|
public static AdminPanel Start(ServerHost host, ServerConfig config, string dataDir)
|
||||||
{
|
{
|
||||||
var panel = new AdminPanel(server, config, dataDir);
|
var panel = new AdminPanel(host, config, dataDir);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
panel.StartWeb();
|
panel.StartWeb();
|
||||||
@@ -160,12 +162,8 @@ public sealed class AdminPanel
|
|||||||
GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
|
GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Neustart im selben Programm: das Panel (und die Anmeldungen) bleiben, der Server ist neu.</summary>
|
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
|
||||||
public void Attach(GameServer newServer, ServerConfig newConfig)
|
public void UpdateConfig(ServerConfig newConfig) => config = newConfig;
|
||||||
{
|
|
||||||
server = newServer;
|
|
||||||
config = newConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
@@ -354,14 +352,14 @@ public sealed class AdminPanel
|
|||||||
app.MapGet("/api/status", async (HttpContext ctx) =>
|
app.MapGet("/api/status", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
||||||
return await Run(() => server.PanelStatus());
|
return await Run(s => s.PanelStatus(), () => host.OfflineStatus());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapGet("/api/players", async (HttpContext ctx) =>
|
app.MapGet("/api/players", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
||||||
bool withAddress = user.Role >= PanelRole.Admin; // IP-Adressen nur für Admins
|
bool withAddress = user.Role >= PanelRole.Admin; // IP-Adressen nur für Admins
|
||||||
return await Run(() => server.PanelPlayers(withAddress));
|
return await Run(s => s.PanelPlayers(withAddress), () => Array.Empty<object>());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/players/{id:int}/{action}", async (HttpContext ctx, int id, string action) =>
|
app.MapPost("/api/players/{id:int}/{action}", async (HttpContext ctx, int id, string action) =>
|
||||||
@@ -373,10 +371,10 @@ public sealed class AdminPanel
|
|||||||
string reason = Str(body, "reason");
|
string reason = Str(body, "reason");
|
||||||
return action switch
|
return action switch
|
||||||
{
|
{
|
||||||
"kick" => await RunText(() => server.PanelKick(id, false, reason, by)),
|
"kick" => await RunText(s => s.PanelKick(id, false, reason, by)),
|
||||||
"ban" => await RunText(() => server.PanelKick(id, true, reason, by)),
|
"ban" => await RunText(s => s.PanelKick(id, true, reason, by)),
|
||||||
"op" => await RunText(() => server.PanelSetAdmin(id, true, by)),
|
"op" => await RunText(s => s.PanelSetAdmin(id, true, by)),
|
||||||
"deop" => await RunText(() => server.PanelSetAdmin(id, false, by)),
|
"deop" => await RunText(s => s.PanelSetAdmin(id, false, by)),
|
||||||
_ => Error(404, "Unknown action")
|
_ => Error(404, "Unknown action")
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -386,14 +384,14 @@ public sealed class AdminPanel
|
|||||||
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
||||||
var body = await Body(ctx);
|
var body = await Body(ctx);
|
||||||
string text = Str(body, "text"), by = By(user);
|
string text = Str(body, "text"), by = By(user);
|
||||||
return await RunText(() => server.PanelSay(text, by));
|
return await RunText(s => s.PanelSay(text, by));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Banns
|
// --- Banns
|
||||||
app.MapGet("/api/bans", async (HttpContext ctx) =>
|
app.MapGet("/api/bans", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
||||||
return await Run(() => server.PanelBans());
|
return await Run(s => s.PanelBans(), () => config.BannedSteamIds.ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/bans/add", async (HttpContext ctx) =>
|
app.MapPost("/api/bans/add", async (HttpContext ctx) =>
|
||||||
@@ -401,7 +399,7 @@ public sealed class AdminPanel
|
|||||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
var body = await Body(ctx);
|
var body = await Body(ctx);
|
||||||
string id = Str(body, "id"), reason = Str(body, "reason"), by = By(user);
|
string id = Str(body, "id"), reason = Str(body, "reason"), by = By(user);
|
||||||
return await RunText(() => server.PanelBanId(id, reason, by));
|
return await RunText(s => s.PanelBanId(id, reason, by), () => OfflineBan(id, true, by));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/bans/remove", async (HttpContext ctx) =>
|
app.MapPost("/api/bans/remove", async (HttpContext ctx) =>
|
||||||
@@ -409,7 +407,7 @@ public sealed class AdminPanel
|
|||||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
var body = await Body(ctx);
|
var body = await Body(ctx);
|
||||||
string id = Str(body, "id"), by = By(user);
|
string id = Str(body, "id"), by = By(user);
|
||||||
return await RunText(() => server.PanelUnban(id, by));
|
return await RunText(s => s.PanelUnban(id, by), () => OfflineBan(id, false, by));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Konsole
|
// --- Konsole
|
||||||
@@ -432,14 +430,23 @@ public sealed class AdminPanel
|
|||||||
string command = Str(body, "command").Trim();
|
string command = Str(body, "command").Trim();
|
||||||
if (command.Length == 0 || command.Length > 500) return Error(400, "Enter a command");
|
if (command.Length == 0 || command.Length > 500) return Error(400, "Enter a command");
|
||||||
string by = By(user);
|
string by = By(user);
|
||||||
return await RunText(() => server.RunCommandCaptured(command, by));
|
if (host.State != HostState.Running || host.Server == null)
|
||||||
|
{
|
||||||
|
// Server aus: Host-Befehle (start, status, panel ...) - Ausgabe aus dem Log einsammeln
|
||||||
|
long from = LogBuffer.LastSeq;
|
||||||
|
GameServer.Log($"> {command} ({by})");
|
||||||
|
host.Command(command);
|
||||||
|
await Task.Delay(400);
|
||||||
|
return Ok(string.Join("\n", LogBuffer.Since(from, 200).Skip(1).Select(l => l.Text)));
|
||||||
|
}
|
||||||
|
return await RunText(s => s.RunCommandCaptured(command, by));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Welt / Backups
|
// --- Welt / Backups
|
||||||
app.MapGet("/api/backups", async (HttpContext ctx) =>
|
app.MapGet("/api/backups", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
return await Run(() => server.PanelBackups());
|
return await Run(s => s.PanelBackups(), () => SaveStore.BackupDirs(dataDir).Select(d => Path.GetFileName(d)).ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/world/{action}", async (HttpContext ctx, string action) =>
|
app.MapPost("/api/world/{action}", async (HttpContext ctx, string action) =>
|
||||||
@@ -447,22 +454,29 @@ public sealed class AdminPanel
|
|||||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
var body = await Body(ctx);
|
var body = await Body(ctx);
|
||||||
string name = Str(body, "name"), by = By(user);
|
string name = Str(body, "name"), by = By(user);
|
||||||
return await RunText(() => server.PanelWorld(action, name, by));
|
return await RunText(s => s.PanelWorld(action, name, by), () => OfflineWorld(action, name, by));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Server steuern (Start/Stopp/Neustart)
|
// --- Server steuern (Start/Stopp/Neustart)
|
||||||
app.MapPost("/api/server/{action}", async (HttpContext ctx, string action) =>
|
app.MapPost("/api/server/{action}", async (HttpContext ctx, string action) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, action == "shutdown" ? PanelRole.Owner : PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
string by = By(user);
|
string by = By(user);
|
||||||
return await RunText(() => server.PanelServerAction(action, by));
|
await Task.CompletedTask;
|
||||||
|
return action switch
|
||||||
|
{
|
||||||
|
"start" => Ok(host.RequestStart(by)),
|
||||||
|
"stop" => Ok(host.RequestStop(by)),
|
||||||
|
"restart" => Ok(host.RequestRestart(by)),
|
||||||
|
_ => Error(404, "Unknown action")
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Einstellungen (server.json)
|
// --- Einstellungen (server.json)
|
||||||
app.MapGet("/api/config", async (HttpContext ctx) =>
|
app.MapGet("/api/config", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
return await Run(() => server.PanelConfig());
|
return await Run(s => s.PanelConfig(), () => ConfigEditor.Build(config));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/config", async (HttpContext ctx) =>
|
app.MapPost("/api/config", async (HttpContext ctx) =>
|
||||||
@@ -471,17 +485,17 @@ public sealed class AdminPanel
|
|||||||
var body = await Body(ctx);
|
var body = await Body(ctx);
|
||||||
bool owner = user.Role >= PanelRole.Owner;
|
bool owner = user.Role >= PanelRole.Owner;
|
||||||
string by = By(user);
|
string by = By(user);
|
||||||
return await RunText(() => server.PanelUpdateConfig(body, owner, by));
|
return await RunText(s => s.PanelUpdateConfig(body, owner, by), () => ConfigEditor.Apply(config, host.ConfigPath, body, owner, by));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Spielstand als ZIP
|
// --- Spielstand als ZIP
|
||||||
app.MapGet("/api/save/download", async (HttpContext ctx) =>
|
app.MapGet("/api/save/download", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
var task = server.Invoke(() => server.ExportSaveZip());
|
await Task.CompletedTask;
|
||||||
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy");
|
if (!SaveStore.HasSave(dataDir)) return Error(404, "There is no save game on the server yet");
|
||||||
GameServer.Log($"[panel] {user.Name} downloaded the save game");
|
GameServer.Log($"[panel] {user.Name} downloaded the save game");
|
||||||
return Results.File(await task, "application/zip", $"msco-save-{DateTime.Now:yyyyMMdd-HHmmss}.zip");
|
return Results.File(SaveStore.ExportZip(dataDir), "application/zip", $"msco-save-{DateTime.Now:yyyyMMdd-HHmmss}.zip");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/save/upload", async (HttpContext ctx) =>
|
app.MapPost("/api/save/upload", async (HttpContext ctx) =>
|
||||||
@@ -500,7 +514,7 @@ public sealed class AdminPanel
|
|||||||
catch (Exception e) { return Error(400, "Upload failed: " + e.Message); }
|
catch (Exception e) { return Error(400, "Upload failed: " + e.Message); }
|
||||||
if (zip.Length == 0) return Error(400, "Choose a ZIP file");
|
if (zip.Length == 0) return Error(400, "Choose a ZIP file");
|
||||||
string by = By(user);
|
string by = By(user);
|
||||||
return await RunText(() => server.ImportSaveZip(zip, by));
|
return await RunText(s => s.ImportSaveZip(zip, by), () => SaveStore.ImportZipOffline(dataDir, zip, by));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Benutzer (nur Owner)
|
// --- Benutzer (nur Owner)
|
||||||
@@ -568,20 +582,66 @@ public sealed class AdminPanel
|
|||||||
|
|
||||||
static string By(PanelUsers.User user) => user.Name + " (panel)";
|
static string By(PanelUsers.User user) => user.Name + " (panel)";
|
||||||
|
|
||||||
async Task<IResult> Run<T>(Func<T> func)
|
const string NotRunning = "The game server is not running - start it on the dashboard";
|
||||||
|
|
||||||
|
/// <summary>In der Server-Schleife ausführen; ist der Server aus, whenStopped (oder Fehler 409).</summary>
|
||||||
|
async Task<IResult> Run<T>(Func<GameServer, T> func, Func<object> whenStopped = null)
|
||||||
{
|
{
|
||||||
var task = server.Invoke(func);
|
var server = host.Server;
|
||||||
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping");
|
if (server == null || host.State != HostState.Running)
|
||||||
|
return whenStopped != null ? Results.Json(whenStopped()) : Error(409, NotRunning);
|
||||||
|
var task = server.Invoke(() => func(server));
|
||||||
|
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task)
|
||||||
|
return whenStopped != null ? Results.Json(whenStopped()) : Error(503, "The server is busy or stopping");
|
||||||
return Results.Json(await task);
|
return Results.Json(await task);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<IResult> RunText(Func<string> func)
|
async Task<IResult> RunText(Func<GameServer, string> func, Func<string> whenStopped = null)
|
||||||
{
|
{
|
||||||
var task = server.Invoke(func);
|
var server = host.Server;
|
||||||
|
if (server == null || host.State != HostState.Running)
|
||||||
|
return whenStopped != null ? Ok(whenStopped()) : Error(409, NotRunning);
|
||||||
|
var task = server.Invoke(() => func(server));
|
||||||
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping");
|
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping");
|
||||||
return Ok(await task);
|
return Ok(await task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Server aus: Bann direkt in server.json setzen bzw. aufheben.</summary>
|
||||||
|
string OfflineBan(string id, bool ban, string by)
|
||||||
|
{
|
||||||
|
id = (id ?? "").Trim();
|
||||||
|
if (ban)
|
||||||
|
{
|
||||||
|
bool valid = (id.Length == 17 && id.All(char.IsDigit)) || (id.StartsWith("name:") && id.Length > 5 && id.Length < 60);
|
||||||
|
if (!valid) return "Enter a SteamID64 (17 digits) or name:playername";
|
||||||
|
if (config.BannedSteamIds.Contains(id)) return id + " is already banned";
|
||||||
|
config.BannedSteamIds.Add(id);
|
||||||
|
}
|
||||||
|
else if (!config.BannedSteamIds.Remove(id)) return id + " is not banned";
|
||||||
|
try { config.Save(host.ConfigPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; }
|
||||||
|
GameServer.Log($"{id} was {(ban ? "banned" : "unbanned")} by {by}");
|
||||||
|
return id + (ban ? " is banned" : " is no longer banned");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server aus: Spielstand-Aktionen direkt auf den Dateien.</summary>
|
||||||
|
string OfflineWorld(string action, string name, string by)
|
||||||
|
{
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case "backup":
|
||||||
|
if (!SaveStore.HasSave(dataDir)) return "There is no save game to back up";
|
||||||
|
SaveStore.Backup(dataDir);
|
||||||
|
GameServer.Log($"Save game backed up by {by}");
|
||||||
|
return "Backup created";
|
||||||
|
case "reset": return SaveStore.ResetOffline(dataDir, by);
|
||||||
|
case "restore": return SaveStore.RestoreOffline(dataDir, name, by);
|
||||||
|
case "resetpoint": return SaveStore.SetResetPoint(dataDir) ?? "The current save game is now the reset point";
|
||||||
|
case "reloadsave": return "The server is stopped - it loads the save game when you start it";
|
||||||
|
case "save": return "The server is stopped - nothing to save";
|
||||||
|
default: return "Unknown action";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static IResult Ok(string message) => Results.Json(new { ok = true, message });
|
static IResult Ok(string message) => Results.Json(new { ok = true, message });
|
||||||
static IResult Error(int status, string message) => Results.Json(new { error = message }, statusCode: status);
|
static IResult Error(int status, string message) => Results.Json(new { error = message }, statusCode: status);
|
||||||
static IResult Forbidden(string message) => Error(403, message);
|
static IResult Forbidden(string message) => Error(403, message);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// server.json im Web-Panel lesen und ändern - bei laufendem Server (dann sofort wirksam) und wenn er aus ist
|
||||||
|
/// (z.B. Lizenzschlüssel eintragen, damit er überhaupt starten kann).
|
||||||
|
/// </summary>
|
||||||
|
public static class ConfigEditor
|
||||||
|
{
|
||||||
|
public static object Build(ServerConfig config) => new
|
||||||
|
{
|
||||||
|
name = config.Name,
|
||||||
|
description = config.Description,
|
||||||
|
maxPlayers = config.MaxPlayers,
|
||||||
|
isPublic = config.Public,
|
||||||
|
requireSteam = config.RequireSteam,
|
||||||
|
publicAddress = config.PublicAddress,
|
||||||
|
saveSyncMinutes = config.SaveSyncMinutes,
|
||||||
|
autosaveSeconds = config.AutosaveSeconds,
|
||||||
|
port = config.Port,
|
||||||
|
panelEnabled = config.PanelEnabled,
|
||||||
|
panelPort = config.PanelPort,
|
||||||
|
panelBindAddress = config.PanelBindAddress,
|
||||||
|
panelTrustProxy = config.PanelTrustProxy,
|
||||||
|
panelBasePath = config.PanelBasePath,
|
||||||
|
licenseKeySet = !string.IsNullOrWhiteSpace(config.LicenseKey),
|
||||||
|
steamWebApiKeySet = !string.IsNullOrWhiteSpace(config.SteamWebApiKey),
|
||||||
|
environmentOverrides = config.EnvironmentOverrides
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert.</summary>
|
||||||
|
public static string Apply(ServerConfig config, string configPath, System.Text.Json.JsonElement b, bool owner, string by)
|
||||||
|
{
|
||||||
|
if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings";
|
||||||
|
var changed = new List<string>();
|
||||||
|
bool restartNeeded = false;
|
||||||
|
|
||||||
|
string Text(string name, string current, int max)
|
||||||
|
{
|
||||||
|
if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current;
|
||||||
|
string s = v.GetString().Trim();
|
||||||
|
if (s.Length > max) s = s[..max];
|
||||||
|
if (s != current) changed.Add(name);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
int Number(string name, int current, int min, int max)
|
||||||
|
{
|
||||||
|
if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current;
|
||||||
|
n = Math.Clamp(n, min, max);
|
||||||
|
if (n != current) changed.Add(name);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
bool Flag(string name, bool current)
|
||||||
|
{
|
||||||
|
if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current;
|
||||||
|
bool f = v.GetBoolean();
|
||||||
|
if (f != current) changed.Add(name);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
string name = Text("name", config.Name, 60);
|
||||||
|
if (name.Length == 0) return "The server name cannot be empty";
|
||||||
|
config.Name = name;
|
||||||
|
config.Description = Text("description", config.Description, 300);
|
||||||
|
config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64);
|
||||||
|
config.Public = Flag("isPublic", config.Public);
|
||||||
|
config.RequireSteam = Flag("requireSteam", config.RequireSteam);
|
||||||
|
config.PublicAddress = Text("publicAddress", config.PublicAddress, 200);
|
||||||
|
config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240);
|
||||||
|
config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600);
|
||||||
|
|
||||||
|
int before = changed.Count;
|
||||||
|
config.Port = Number("port", config.Port, 1, 65535);
|
||||||
|
config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled);
|
||||||
|
config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535);
|
||||||
|
config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60);
|
||||||
|
config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy);
|
||||||
|
config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60);
|
||||||
|
if (changed.Count > before) restartNeeded = true;
|
||||||
|
|
||||||
|
foreach (string secret in new[] { "licenseKey", "steamWebApiKey" })
|
||||||
|
{
|
||||||
|
if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue;
|
||||||
|
if (!owner) return "Only the owner can change keys";
|
||||||
|
string value = v.GetString().Trim();
|
||||||
|
if (value.Length > 200) return "Key too long";
|
||||||
|
if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value;
|
||||||
|
changed.Add(secret);
|
||||||
|
restartNeeded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed.Count == 0) return "Nothing changed";
|
||||||
|
try { config.Save(configPath); }
|
||||||
|
catch (Exception e) { return "Could not save server.json: " + e.Message; }
|
||||||
|
GameServer.Log($"Settings changed by {by}: {string.Join(", ", changed)}");
|
||||||
|
var overridden = changed.Where(c => config.EnvironmentOverrides.Any(e => e.Replace("_", "").Contains(c.ToUpperInvariant().Replace("ISPUBLIC", "PUBLIC")))).ToList();
|
||||||
|
return "Settings saved" + (restartNeeded ? " - some changes need a restart" : "") +
|
||||||
|
(overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-251
@@ -15,7 +15,8 @@ public sealed class GameServer
|
|||||||
public const string ModVersion = "2.8"; // muss mit MSCO.ModVersion übereinstimmen
|
public const string ModVersion = "2.8"; // muss mit MSCO.ModVersion übereinstimmen
|
||||||
public const int ServerId = 0; // Absender-ID des Servers; Spieler bekommen 1, 2, ...
|
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" };
|
static string[] SaveFiles => SaveStore.SaveFiles;
|
||||||
|
string DataDir => Path.GetDirectoryName(savePath)!;
|
||||||
const double HeartbeatSeconds = 30;
|
const double HeartbeatSeconds = 30;
|
||||||
|
|
||||||
enum ClientState { New, Verifying, Joined }
|
enum ClientState { New, Verifying, Joined }
|
||||||
@@ -55,7 +56,16 @@ public sealed class GameServer
|
|||||||
readonly ConcurrentQueue<Action> actions = new(); // vom Web-Panel: laufen in der Server-Schleife
|
readonly ConcurrentQueue<Action> actions = new(); // vom Web-Panel: laufen in der Server-Schleife
|
||||||
readonly Stopwatch clock = Stopwatch.StartNew();
|
readonly Stopwatch clock = Stopwatch.StartNew();
|
||||||
|
|
||||||
TcpListener listener;
|
// Neue Verbindungen reicht der Host herein (er besitzt den Port und erkennt Browser fürs Panel)
|
||||||
|
readonly ConcurrentQueue<TcpClient> incoming = new();
|
||||||
|
readonly AutoResetEvent incomingSignal = new(false);
|
||||||
|
|
||||||
|
/// <summary>Vom Host: eine neue Spiel-Verbindung.</summary>
|
||||||
|
public void AcceptExternal(TcpClient tcp)
|
||||||
|
{
|
||||||
|
incoming.Enqueue(tcp);
|
||||||
|
incomingSignal.Set();
|
||||||
|
}
|
||||||
List<KeyValuePair<string, byte[]>> saveFiles = new();
|
List<KeyValuePair<string, byte[]>> saveFiles = new();
|
||||||
int nextId = 1;
|
int nextId = 1;
|
||||||
byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand
|
byte[] lastWorldTime; // letzter Uhrzeit-/Wetter-Stand
|
||||||
@@ -80,7 +90,6 @@ public sealed class GameServer
|
|||||||
double nextSaveSync;
|
double nextSaveSync;
|
||||||
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
|
readonly List<Client> waitingForSave = new(); // Beigetretene, die auf den frischen Spielstand warten
|
||||||
const long MaxUploadBytes = 32L * 1024 * 1024;
|
const long MaxUploadBytes = 32L * 1024 * 1024;
|
||||||
const int KeepSaveBackups = 5;
|
|
||||||
/// <summary>So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten.</summary>
|
/// <summary>So lange auf den hochgeladenen Spielstand warten, danach bekommen Wartende den gespeicherten.</summary>
|
||||||
public double SaveUploadTimeoutSeconds { get; set; } = 15;
|
public double SaveUploadTimeoutSeconds { get; set; } = 15;
|
||||||
const int MaxDestroyPerMinute = 20;
|
const int MaxDestroyPerMinute = 20;
|
||||||
@@ -141,9 +150,7 @@ public sealed class GameServer
|
|||||||
public void Run()
|
public void Run()
|
||||||
{
|
{
|
||||||
EnsureResetPoint();
|
EnsureResetPoint();
|
||||||
listener = new TcpListener(IPAddress.Any, config.Port);
|
Log($"Game server running on port {config.Port} - {config.Name} (max {config.MaxPlayers} players)");
|
||||||
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)");
|
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)");
|
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($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions");
|
||||||
@@ -166,9 +173,18 @@ public sealed class GameServer
|
|||||||
|
|
||||||
foreach (var c in clients.Values)
|
foreach (var c in clients.Values)
|
||||||
{
|
{
|
||||||
// Meist ein Neustart (Update, docker compose up): die Spieler verbinden sich automatisch neu, sobald der
|
if (c.State != ClientState.Joined) { c.Conn.CloseAfterSend("Server shutting down"); continue; }
|
||||||
// Server wieder da ist. Bleibt er aus, bekommen sie nach ein paar Versuchen einen Hinweis im Hauptmenü.
|
if (stoppedBy != null && !RestartRequested)
|
||||||
if (c.State == ClientState.Joined) c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write("The server is restarting - reconnecting...")));
|
{
|
||||||
|
// Gezielt gestoppt (Panel/Konsole): Grund zeigen, nicht endlos neu verbinden
|
||||||
|
c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write($"The server was stopped by {stoppedBy}")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Neustart / Update (docker compose up): die Spieler verbinden sich automatisch neu, sobald der Server
|
||||||
|
// wieder da ist. Bleibt er aus, bekommen sie nach ein paar Versuchen einen Hinweis im Hauptmenü.
|
||||||
|
c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write("The server is restarting - reconnecting...")));
|
||||||
|
}
|
||||||
c.Conn.CloseAfterSend("Server shutting down");
|
c.Conn.CloseAfterSend("Server shutting down");
|
||||||
}
|
}
|
||||||
// Kurz warten, bis die Spieler die Meldung gelesen haben (sauber trennen statt hart abbrechen)
|
// Kurz warten, bis die Spieler die Meldung gelesen haben (sauber trennen statt hart abbrechen)
|
||||||
@@ -179,7 +195,6 @@ public sealed class GameServer
|
|||||||
Thread.Sleep(20);
|
Thread.Sleep(20);
|
||||||
}
|
}
|
||||||
foreach (var c in clients.Values) c.Conn.Close("Server shutting down");
|
foreach (var c in clients.Values) c.Conn.Close("Server shutting down");
|
||||||
listener.Stop();
|
|
||||||
SaveWorld();
|
SaveWorld();
|
||||||
if (!offline)
|
if (!offline)
|
||||||
{
|
{
|
||||||
@@ -189,19 +204,13 @@ public sealed class GameServer
|
|||||||
Log("Server stopped.");
|
Log("Server stopped.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Web-Panel (gesetzt vom Programm). Teilt es sich den Spiel-Port, werden Browser hier erkannt und weitergereicht.</summary>
|
/// <summary>Web-Panel (gehört dem Host, läuft auch, wenn dieser Server aus ist).</summary>
|
||||||
public AdminPanel Panel { get; set; }
|
public AdminPanel Panel { get; set; }
|
||||||
readonly ConcurrentQueue<TcpClient> sniffedGameClients = new();
|
public ServerHost Host { get; set; }
|
||||||
|
|
||||||
void AcceptClients()
|
void AcceptClients()
|
||||||
{
|
{
|
||||||
while (listener.Pending())
|
while (incoming.TryDequeue(out var tcp)) AddClient(tcp);
|
||||||
{
|
|
||||||
var tcp = listener.AcceptTcpClient();
|
|
||||||
if (Panel != null && Panel.SharesGamePort) _ = SniffAsync(tcp);
|
|
||||||
else AddClient(tcp);
|
|
||||||
}
|
|
||||||
while (sniffedGameClients.TryDequeue(out var tcp)) AddClient(tcp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AddClient(TcpClient tcp)
|
void AddClient(TcpClient tcp)
|
||||||
@@ -210,35 +219,6 @@ public sealed class GameServer
|
|||||||
clients[c.Id] = c;
|
clients[c.Id] = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gleicher Port für Spiel und Web-Panel: in die ersten Bytes schauen (ohne sie zu verbrauchen). Ein Browser
|
|
||||||
/// schickt "GET /", "POST" ... - das Spiel beginnt mit einer kleinen Längenangabe (Bytes 2-4 sind 0).
|
|
||||||
/// Beide Seiten reden zuerst, der Server muss also nichts senden, bevor er es weiß.
|
|
||||||
/// </summary>
|
|
||||||
async Task SniffAsync(TcpClient tcp)
|
|
||||||
{
|
|
||||||
var buffer = new byte[4];
|
|
||||||
int n;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token);
|
|
||||||
if (n == 0) { tcp.Close(); return; }
|
|
||||||
if (n >= 4 || buffer[0] < 'A' || buffer[0] > 'Z') break; // kein Buchstabe = sicher das Spiel
|
|
||||||
await Task.Delay(15, timeout.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
try { tcp.Close(); } catch { }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (AdminPanel.LooksLikeHttp(buffer, n)) await Panel.RelayAsync(tcp);
|
|
||||||
else sniffedGameClients.Enqueue(tcp);
|
|
||||||
}
|
|
||||||
|
|
||||||
void PollClients()
|
void PollClients()
|
||||||
{
|
{
|
||||||
List<Client> closed = null;
|
List<Client> closed = null;
|
||||||
@@ -285,7 +265,7 @@ public sealed class GameServer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Regelmäßig den aktuellen Spielstand vom Spiel holen
|
// Regelmäßig den aktuellen Spielstand vom Spiel holen
|
||||||
if (config.SaveSyncMinutes > 0 && !GameStopped && now >= nextSaveSync)
|
if (config.SaveSyncMinutes > 0 && now >= nextSaveSync)
|
||||||
{
|
{
|
||||||
nextSaveSync = now + config.SaveSyncMinutes * 60.0;
|
nextSaveSync = now + config.SaveSyncMinutes * 60.0;
|
||||||
RequestSave("regular save");
|
RequestSave("regular save");
|
||||||
@@ -310,7 +290,7 @@ public sealed class GameServer
|
|||||||
else if (result.Status == 0) Log("Master server not reachable - server is not listed right now.");
|
else if (result.Status == 0) Log("Master server not reachable - server is not listed right now.");
|
||||||
else ReportReachability(result);
|
else ReportReachability(result);
|
||||||
}
|
}
|
||||||
if (!offline && !GameStopped && now >= nextHeartbeat && heartbeat == null)
|
if (!offline && now >= nextHeartbeat && heartbeat == null)
|
||||||
{
|
{
|
||||||
nextHeartbeat = now + HeartbeatSeconds;
|
nextHeartbeat = now + HeartbeatSeconds;
|
||||||
heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
|
heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion);
|
||||||
@@ -523,7 +503,6 @@ public sealed class GameServer
|
|||||||
// Andere Version ist erlaubt (der Spieler wurde in der Mod schon gewarnt), nur vermerken
|
// 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 (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 (config.BannedSteamIds.Contains(c.SteamId)) { Reject(c, "You are banned from this server"); return; }
|
||||||
if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; }
|
|
||||||
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
||||||
|
|
||||||
c.State = ClientState.Verifying;
|
c.State = ClientState.Verifying;
|
||||||
@@ -534,7 +513,6 @@ public sealed class GameServer
|
|||||||
{
|
{
|
||||||
string error = c.Verification.Result;
|
string error = c.Verification.Result;
|
||||||
if (error != null) { Reject(c, error); return; }
|
if (error != null) { Reject(c, error); return; }
|
||||||
if (GameStopped) { Reject(c, "The server is stopped at the moment - try again later"); return; }
|
|
||||||
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; }
|
||||||
|
|
||||||
// Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige
|
// Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige
|
||||||
@@ -694,23 +672,10 @@ public sealed class GameServer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Vorhandene Spielstand-Backups, neueste zuerst.</summary>
|
/// <summary>Vorhandene Spielstand-Backups, neueste zuerst.</summary>
|
||||||
List<string> SaveBackups()
|
List<string> SaveBackups() => SaveStore.BackupDirs(DataDir);
|
||||||
{
|
|
||||||
string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups");
|
|
||||||
return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
|
/// <summary>Bisherigen Spielstand nach data/save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
|
||||||
void BackupSave()
|
void BackupSave() => SaveStore.Backup(DataDir);
|
||||||
{
|
|
||||||
if (!Directory.Exists(savePath)) return;
|
|
||||||
string backups = Path.Combine(Path.GetDirectoryName(savePath)!, "save_backups");
|
|
||||||
string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss"));
|
|
||||||
Directory.CreateDirectory(target);
|
|
||||||
foreach (string file in Directory.GetFiles(savePath)) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true);
|
|
||||||
foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepSaveBackups))
|
|
||||||
try { Directory.Delete(old, true); } catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Alle Spieler neu verbinden lassen (nach reset-world / reload-save): sie sehen die Meldung, gehen kurz ins Menü
|
/// Alle Spieler neu verbinden lassen (nach reset-world / reload-save): sie sehen die Meldung, gehen kurz ins Menü
|
||||||
@@ -743,16 +708,9 @@ public sealed class GameServer
|
|||||||
/// <summary>Aktuellen Spielstand als Reset-Punkt merken.</summary>
|
/// <summary>Aktuellen Spielstand als Reset-Punkt merken.</summary>
|
||||||
bool SetResetPoint()
|
bool SetResetPoint()
|
||||||
{
|
{
|
||||||
if (!File.Exists(Path.Combine(savePath, "defaultES2File.txt"))) return false;
|
string error = SaveStore.SetResetPoint(DataDir);
|
||||||
try
|
if (error != null && SaveStore.HasSave(DataDir)) Log(error);
|
||||||
{
|
return error == null;
|
||||||
if (Directory.Exists(originalPath)) Directory.Delete(originalPath, true);
|
|
||||||
Directory.CreateDirectory(originalPath);
|
|
||||||
foreach (string file in Directory.GetFiles(savePath))
|
|
||||||
if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(originalPath, Path.GetFileName(file)), true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception e) { Log("Could not remember the reset point: " + e.Message); return false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -870,11 +828,14 @@ public sealed class GameServer
|
|||||||
void WaitForTraffic()
|
void WaitForTraffic()
|
||||||
{
|
{
|
||||||
waitList.Clear();
|
waitList.Clear();
|
||||||
waitList.Add(listener.Server);
|
|
||||||
foreach (var c in clients.Values)
|
foreach (var c in clients.Values)
|
||||||
if (!c.Conn.IsClosed) waitList.Add(c.Conn.Socket);
|
if (!c.Conn.IsClosed) waitList.Add(c.Conn.Socket);
|
||||||
int micros = clients.Count == 0 ? 50_000 : 5_000; // leerer Server: seltener aufwachen
|
if (waitList.Count == 0)
|
||||||
try { Socket.Select(waitList, null, null, micros); }
|
{
|
||||||
|
incomingSignal.WaitOne(50); // leerer Server: warten, bis jemand verbindet (oder Timer fällig)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { Socket.Select(waitList, null, null, 5_000); }
|
||||||
catch { Thread.Sleep(2); } // z.B. Socket wurde gerade geschlossen
|
catch { Thread.Sleep(2); } // z.B. Socket wurde gerade geschlossen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,7 +860,7 @@ public sealed class GameServer
|
|||||||
switch (args[0].ToLowerInvariant())
|
switch (args[0].ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "help":
|
case "help":
|
||||||
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | panel | stop-game | start-game | restart | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
|
Log("status | players | kick <id> [reason] | ban <id> [reason] | bans | unban <SteamID|name:x> | backups | backup | restore-save <n> | set-reset-point | panel | restart | stop | exit | say <text> | op <id> | deop <id> | admins | save | reset-world | reload-save | stop");
|
||||||
break;
|
break;
|
||||||
case "say":
|
case "say":
|
||||||
if (args.Length < 2) { Log("say <text>"); break; }
|
if (args.Length < 2) { Log("say <text>"); break; }
|
||||||
@@ -1009,21 +970,20 @@ public sealed class GameServer
|
|||||||
Log(SetResetPoint() ? "The current save game is now the reset point" : "No save game to use as reset point");
|
Log(SetResetPoint() ? "The current save game is now the reset point" : "No save game to use as reset point");
|
||||||
break;
|
break;
|
||||||
case "stop":
|
case "stop":
|
||||||
|
stoppedBy = "Console";
|
||||||
|
running = false;
|
||||||
|
Log(Panel != null ? "Stopping the game server - the web panel keeps running ('start' to start again, 'exit' to quit)" : "Stopping ...");
|
||||||
|
break;
|
||||||
case "quit":
|
case "quit":
|
||||||
case "exit":
|
case "exit":
|
||||||
running = false;
|
RequestExit();
|
||||||
break;
|
break;
|
||||||
case "restart":
|
case "restart":
|
||||||
Log("Restarting (settings, license and save game are loaded again) ...");
|
Log("Restarting (settings, license and save game are loaded again) ...");
|
||||||
RestartRequested = true;
|
RestartRequested = true;
|
||||||
running = false;
|
running = false;
|
||||||
break;
|
break;
|
||||||
case "stop-game":
|
|
||||||
Log(StopGame("Console"));
|
|
||||||
break;
|
|
||||||
case "start-game":
|
|
||||||
Log(StartGame("Console"));
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
Log("Unknown command, type 'help'");
|
Log("Unknown command, type 'help'");
|
||||||
break;
|
break;
|
||||||
@@ -1106,7 +1066,7 @@ public sealed class GameServer
|
|||||||
version = ModVersion,
|
version = ModVersion,
|
||||||
players = JoinedCount,
|
players = JoinedCount,
|
||||||
maxPlayers = config.MaxPlayers,
|
maxPlayers = config.MaxPlayers,
|
||||||
gameStopped = GameStopped,
|
state = "running",
|
||||||
uptimeSeconds = (long)clock.Elapsed.TotalSeconds,
|
uptimeSeconds = (long)clock.Elapsed.TotalSeconds,
|
||||||
port = config.Port,
|
port = config.Port,
|
||||||
isPublic = config.Public,
|
isPublic = config.Public,
|
||||||
@@ -1137,153 +1097,51 @@ public sealed class GameServer
|
|||||||
|
|
||||||
public object PanelBans() => config.BannedSteamIds.ToList();
|
public object PanelBans() => config.BannedSteamIds.ToList();
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Start / Stopp / Neustart
|
// ---------------------------------------------------------------- Stopp / Neustart (der Host startet neu oder wartet)
|
||||||
|
|
||||||
/// <summary>Spiel offline (Panel läuft weiter): Spieler raus, nicht mehr gelistet, niemand kann beitreten.</summary>
|
|
||||||
public bool GameStopped { get; private set; }
|
|
||||||
/// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary>
|
/// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary>
|
||||||
public bool RestartRequested { get; private set; }
|
public bool RestartRequested { get; private set; }
|
||||||
|
/// <summary>Ganzes Programm beenden (Konsole "exit", docker stop) - sonst bleibt das Panel an.</summary>
|
||||||
|
public bool ExitRequested { get; private set; }
|
||||||
|
string stoppedBy; // gesetzt = gezielt gestoppt (Spieler bekommen den Grund statt "verbinde neu")
|
||||||
|
|
||||||
public string StopGame(string by)
|
public void RequestExit()
|
||||||
{
|
{
|
||||||
if (GameStopped) return "The game server is already stopped";
|
ExitRequested = true;
|
||||||
foreach (var c in clients.Values.Where(x => x.State == ClientState.Joined).ToList())
|
running = false;
|
||||||
Reject(c, $"The server was stopped by {by}");
|
|
||||||
GameStopped = true;
|
|
||||||
saveUpload = null;
|
|
||||||
waitingForSave.Clear();
|
|
||||||
SaveWorld();
|
|
||||||
Log($"Game server stopped by {by} - players cannot join until it is started again (the panel keeps running)");
|
|
||||||
return "Game server stopped";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string StartGame(string by)
|
|
||||||
{
|
|
||||||
if (!GameStopped) return "The game server is already running";
|
|
||||||
if (!LoadSave()) return "Could not load the save game - see the console";
|
|
||||||
GameStopped = false;
|
|
||||||
HeartbeatSoon();
|
|
||||||
Log($"Game server started by {by}");
|
|
||||||
return "Game server started";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string PanelServerAction(string action, string by)
|
public string PanelServerAction(string action, string by)
|
||||||
{
|
{
|
||||||
switch (action)
|
switch (action)
|
||||||
{
|
{
|
||||||
case "stop-game": return StopGame(by);
|
case "stop":
|
||||||
case "start-game": return StartGame(by);
|
Log($"Server stopped by {by} - the panel keeps running");
|
||||||
|
stoppedBy = by;
|
||||||
|
running = false;
|
||||||
|
return "Stopping the server";
|
||||||
case "restart":
|
case "restart":
|
||||||
Log($"Restart requested by {by} ...");
|
Log($"Restart requested by {by} ...");
|
||||||
RestartRequested = true;
|
RestartRequested = true;
|
||||||
running = false;
|
running = false;
|
||||||
return "Restarting - the panel is back in a few seconds";
|
return "Restarting - the panel is back in a few seconds";
|
||||||
case "shutdown":
|
|
||||||
Log($"Shutdown requested by {by} - the server and the panel stop");
|
|
||||||
running = false;
|
|
||||||
return "Shutting down";
|
|
||||||
default: return "Unknown action";
|
default: return "Unknown action";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Einstellungen (server.json) im Panel
|
// ---------------------------------------------------------------- Einstellungen (server.json) im Panel
|
||||||
|
|
||||||
public object PanelConfig() => new
|
public object PanelConfig() => ConfigEditor.Build(config);
|
||||||
{
|
|
||||||
name = config.Name,
|
|
||||||
description = config.Description,
|
|
||||||
maxPlayers = config.MaxPlayers,
|
|
||||||
isPublic = config.Public,
|
|
||||||
requireSteam = config.RequireSteam,
|
|
||||||
publicAddress = config.PublicAddress,
|
|
||||||
saveSyncMinutes = config.SaveSyncMinutes,
|
|
||||||
autosaveSeconds = config.AutosaveSeconds,
|
|
||||||
port = config.Port,
|
|
||||||
panelEnabled = config.PanelEnabled,
|
|
||||||
panelPort = config.PanelPort,
|
|
||||||
panelBindAddress = config.PanelBindAddress,
|
|
||||||
panelTrustProxy = config.PanelTrustProxy,
|
|
||||||
panelBasePath = config.PanelBasePath,
|
|
||||||
licenseKeySet = !string.IsNullOrWhiteSpace(config.LicenseKey),
|
|
||||||
steamWebApiKeySet = !string.IsNullOrWhiteSpace(config.SteamWebApiKey),
|
|
||||||
environmentOverrides = config.EnvironmentOverrides
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Einstellungen ändern. Schlüssel (Lizenz, Steam Web API) nur als Owner; leer = unverändert.</summary>
|
|
||||||
public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by)
|
public string PanelUpdateConfig(System.Text.Json.JsonElement b, bool owner, string by)
|
||||||
{
|
{
|
||||||
if (b.ValueKind != System.Text.Json.JsonValueKind.Object) return "Invalid settings";
|
string result = ConfigEditor.Apply(config, configPath, b, owner, by);
|
||||||
var changed = new List<string>();
|
|
||||||
bool restartNeeded = false;
|
|
||||||
|
|
||||||
string Text(string name, string current, int max)
|
|
||||||
{
|
|
||||||
if (!b.TryGetProperty(name, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String) return current;
|
|
||||||
string s = v.GetString().Trim();
|
|
||||||
if (s.Length > max) s = s[..max];
|
|
||||||
if (s != current) changed.Add(name);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
int Number(string name, int current, int min, int max)
|
|
||||||
{
|
|
||||||
if (!b.TryGetProperty(name, out var v) || !v.TryGetInt32(out int n)) return current;
|
|
||||||
n = Math.Clamp(n, min, max);
|
|
||||||
if (n != current) changed.Add(name);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
bool Flag(string name, bool current)
|
|
||||||
{
|
|
||||||
if (!b.TryGetProperty(name, out var v) || (v.ValueKind != System.Text.Json.JsonValueKind.True && v.ValueKind != System.Text.Json.JsonValueKind.False)) return current;
|
|
||||||
bool f = v.GetBoolean();
|
|
||||||
if (f != current) changed.Add(name);
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
string name = Text("name", config.Name, 60);
|
|
||||||
if (name.Length == 0) return "The server name cannot be empty";
|
|
||||||
config.Name = name;
|
|
||||||
config.Description = Text("description", config.Description, 300);
|
|
||||||
config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64);
|
|
||||||
config.Public = Flag("isPublic", config.Public);
|
|
||||||
config.RequireSteam = Flag("requireSteam", config.RequireSteam);
|
|
||||||
config.PublicAddress = Text("publicAddress", config.PublicAddress, 200);
|
|
||||||
config.SaveSyncMinutes = Number("saveSyncMinutes", config.SaveSyncMinutes, 0, 240);
|
|
||||||
config.AutosaveSeconds = Number("autosaveSeconds", config.AutosaveSeconds, 10, 3600);
|
|
||||||
|
|
||||||
int before = changed.Count;
|
|
||||||
config.Port = Number("port", config.Port, 1, 65535);
|
|
||||||
config.PanelEnabled = Flag("panelEnabled", config.PanelEnabled);
|
|
||||||
config.PanelPort = Number("panelPort", config.PanelPort, 0, 65535);
|
|
||||||
config.PanelBindAddress = Text("panelBindAddress", config.PanelBindAddress, 60);
|
|
||||||
config.PanelTrustProxy = Flag("panelTrustProxy", config.PanelTrustProxy);
|
|
||||||
config.PanelBasePath = Text("panelBasePath", config.PanelBasePath, 60);
|
|
||||||
if (changed.Count > before) restartNeeded = true;
|
|
||||||
|
|
||||||
foreach (string secret in new[] { "licenseKey", "steamWebApiKey" })
|
|
||||||
{
|
|
||||||
if (!b.TryGetProperty(secret, out var v) || v.ValueKind != System.Text.Json.JsonValueKind.String || v.GetString().Trim().Length == 0) continue;
|
|
||||||
if (!owner) return "Only the owner can change keys";
|
|
||||||
string value = v.GetString().Trim();
|
|
||||||
if (value.Length > 200) return "Key too long";
|
|
||||||
if (secret == "licenseKey") config.LicenseKey = value; else config.SteamWebApiKey = value;
|
|
||||||
changed.Add(secret);
|
|
||||||
restartNeeded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed.Count == 0) return "Nothing changed";
|
|
||||||
try { config.Save(configPath); }
|
|
||||||
catch (Exception e) { return "Could not save server.json: " + e.Message; }
|
|
||||||
HeartbeatSoon(); // Name/Beschreibung/Spielerzahl gleich an die Serverliste
|
HeartbeatSoon(); // Name/Beschreibung/Spielerzahl gleich an die Serverliste
|
||||||
Log($"Settings changed by {by}: {string.Join(", ", changed)}");
|
return result;
|
||||||
var overridden = changed.Where(c => config.EnvironmentOverrides.Any(e => e.Replace("_", "").Contains(c.ToUpperInvariant().Replace("ISPUBLIC", "PUBLIC")))).ToList();
|
|
||||||
return "Settings saved" + (restartNeeded ? " - some changes need a restart" : "") +
|
|
||||||
(overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen
|
// ---------------------------------------------------------------- Spielstand als ZIP hoch-/herunterladen
|
||||||
|
|
||||||
const long MaxSaveZip = 64L * 1024 * 1024;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spielstand aus einer ZIP übernehmen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem
|
/// Spielstand aus einer ZIP übernehmen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem
|
||||||
/// Ordner in der ZIP) - Pfade aus der ZIP werden nie benutzt. Danach: Backup, laden, neuer Reset-Punkt, Welt leer,
|
/// Ordner in der ZIP) - Pfade aus der ZIP werden nie benutzt. Danach: Backup, laden, neuer Reset-Punkt, Welt leer,
|
||||||
@@ -1291,41 +1149,12 @@ public sealed class GameServer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string ImportSaveZip(byte[] zip, string by)
|
public string ImportSaveZip(byte[] zip, string by)
|
||||||
{
|
{
|
||||||
var found = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
string error = SaveStore.ExtractZip(zip, out var found);
|
||||||
try
|
if (error != null) return error;
|
||||||
{
|
|
||||||
using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(zip), System.IO.Compression.ZipArchiveMode.Read);
|
|
||||||
long total = 0;
|
|
||||||
foreach (var entry in archive.Entries)
|
|
||||||
{
|
|
||||||
string file = Path.GetFileName(entry.FullName.Replace('\\', '/'));
|
|
||||||
string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (known == null) continue;
|
|
||||||
if (entry.Length > MaxSaveZip || (total += entry.Length) > MaxSaveZip) return "The save game is too large";
|
|
||||||
using var s = entry.Open();
|
|
||||||
using var ms = new MemoryStream();
|
|
||||||
s.CopyTo(ms);
|
|
||||||
found[known] = ms.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InvalidDataException) { return "This is not a valid ZIP file"; }
|
|
||||||
if (!found.ContainsKey("defaultES2File.txt"))
|
|
||||||
return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car";
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
BackupSave();
|
BackupSave();
|
||||||
Directory.CreateDirectory(savePath);
|
SaveStore.WriteSave(DataDir, found);
|
||||||
foreach (string name in SaveFiles)
|
|
||||||
{
|
|
||||||
string target = Path.Combine(savePath, name);
|
|
||||||
if (found.TryGetValue(name, out var data))
|
|
||||||
{
|
|
||||||
File.WriteAllBytes(target + ".tmp", data);
|
|
||||||
File.Move(target + ".tmp", target, true);
|
|
||||||
}
|
|
||||||
else if (File.Exists(target)) File.Delete(target); // gehört zum alten Spielstand
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception e) { return "Could not write the save game: " + e.Message; }
|
catch (Exception e) { return "Could not write the save game: " + e.Message; }
|
||||||
if (!LoadSave()) return "The uploaded save game could not be loaded - see the console";
|
if (!LoadSave()) return "The uploaded save game could not be loaded - see the console";
|
||||||
@@ -1340,19 +1169,7 @@ public sealed class GameServer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Aktuellen Spielstand als ZIP.</summary>
|
/// <summary>Aktuellen Spielstand als ZIP.</summary>
|
||||||
public byte[] ExportSaveZip()
|
public byte[] ExportSaveZip() => SaveStore.ExportZip(DataDir);
|
||||||
{
|
|
||||||
using var ms = new MemoryStream();
|
|
||||||
using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, true))
|
|
||||||
{
|
|
||||||
foreach (string name in SaveFiles)
|
|
||||||
{
|
|
||||||
string file = Path.Combine(savePath, name);
|
|
||||||
if (File.Exists(file)) System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, file, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ms.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public object PanelBackups() => SaveBackups().Select(d => Path.GetFileName(d)).ToList();
|
public object PanelBackups() => SaveBackups().Select(d => Path.GetFileName(d)).ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,15 @@ tbody tr:hover { background: rgba(255, 255, 255, .02); }
|
|||||||
.toggle input { width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--accent); flex: none; }
|
.toggle input { width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--accent); flex: none; }
|
||||||
input[type=file] { padding: 7px; }
|
input[type=file] { padding: 7px; }
|
||||||
input[type=file]::file-selector-button { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 5px 10px; margin-right: 10px; cursor: pointer; }
|
input[type=file]::file-selector-button { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 5px 10px; margin-right: 10px; cursor: pointer; }
|
||||||
|
.control { display: grid; gap: 14px; }
|
||||||
|
.state-label { font-size: 13px; font-weight: 600; color: var(--muted); display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.state-label::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--pending); }
|
||||||
|
.state-label.running::before { background: var(--online); }
|
||||||
|
.state-label.error::before { background: var(--danger); }
|
||||||
|
.notice { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; padding: 12px 14px;
|
||||||
|
border-radius: 10px; background: rgba(242, 194, 48, .08); border: 1px solid rgba(242, 194, 48, .25); }
|
||||||
|
.notice.bad { background: rgba(229, 72, 77, .08); border-color: rgba(229, 72, 77, .3); }
|
||||||
|
.notice b { display: block; margin-bottom: 2px; }
|
||||||
.console-input { display: flex; gap: 10px; padding: 12px; border-top: 1px solid var(--border); background: var(--panel); }
|
.console-input { display: flex; gap: 10px; padding: 12px; border-top: 1px solid var(--border); background: var(--panel); }
|
||||||
.console-input input { font-family: var(--mono); }
|
.console-input input { font-family: var(--mono); }
|
||||||
|
|
||||||
|
|||||||
+40
-19
@@ -200,11 +200,12 @@ async function refreshTopbar() {
|
|||||||
const s = await api("GET", "status");
|
const s = await api("GET", "status");
|
||||||
state.status = s;
|
state.status = s;
|
||||||
$("serverName").textContent = s.name;
|
$("serverName").textContent = s.name;
|
||||||
$("serverMeta").textContent = s.gameStopped
|
const running = s.state === "running";
|
||||||
? `game server stopped · v${s.version}`
|
$("serverMeta").textContent = running
|
||||||
: `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}`;
|
? `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}`
|
||||||
$("statusDot").className = "status-dot " + (s.gameStopped ? "" : "ok");
|
: `server ${s.state} · v${s.version}`;
|
||||||
$("statusDot").title = s.gameStopped ? "Game server stopped" : "Online";
|
$("statusDot").className = "status-dot " + (running ? "ok" : s.state === "error" ? "bad" : "");
|
||||||
|
$("statusDot").title = running ? "Online" : "Server " + s.state + (s.lastError ? ": " + s.lastError : "");
|
||||||
document.title = `${s.name} · MSCO Panel`;
|
document.title = `${s.name} · MSCO Panel`;
|
||||||
} catch {
|
} catch {
|
||||||
$("statusDot").className = "status-dot bad";
|
$("statusDot").className = "status-dot bad";
|
||||||
@@ -215,9 +216,9 @@ async function refreshTopbar() {
|
|||||||
|
|
||||||
function renderDashboard(page) {
|
function renderDashboard(page) {
|
||||||
const cards = h("div", { class: "cards" });
|
const cards = h("div", { class: "cards" });
|
||||||
const control = h("div", { class: "section-body row" });
|
const control = h("div", { class: "section-body control" });
|
||||||
if (can("Admin")) page.append(h("div", { class: "section" },
|
if (can("Admin")) page.append(h("div", { class: "section" },
|
||||||
h("div", { class: "section-head" }, h("h2", {}, "Server control"), h("span", { class: "muted", id: "gameState" }, "")),
|
h("div", { class: "section-head" }, h("h2", {}, "Server"), h("span", { class: "state-label", id: "gameState" }, "")),
|
||||||
control));
|
control));
|
||||||
const recent = h("div", { class: "console small" });
|
const recent = h("div", { class: "console small" });
|
||||||
page.append(
|
page.append(
|
||||||
@@ -260,20 +261,40 @@ async function serverAction(action, confirmText) {
|
|||||||
}
|
}
|
||||||
await act(api("POST", "server/" + action));
|
await act(api("POST", "server/" + action));
|
||||||
setTimeout(refreshTopbar, 800);
|
setTimeout(refreshTopbar, 800);
|
||||||
|
setTimeout(refreshTopbar, 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATE_TEXT = {
|
||||||
|
running: "Running - players can join",
|
||||||
|
starting: "Starting …",
|
||||||
|
stopped: "Stopped - players cannot join. The panel keeps running.",
|
||||||
|
error: "Not running",
|
||||||
|
};
|
||||||
|
|
||||||
function renderControl(el, s) {
|
function renderControl(el, s) {
|
||||||
const state = $("gameState");
|
const label = $("gameState");
|
||||||
if (state) state.textContent = s.gameStopped ? "game server is stopped - players cannot join" : "game server is running";
|
const running = s.state === "running", starting = s.state === "starting";
|
||||||
el.replaceChildren(
|
if (label) {
|
||||||
s.gameStopped
|
label.textContent = STATE_TEXT[s.state] || s.state;
|
||||||
? h("button", { class: "btn primary", type: "button", onclick: () => serverAction("start-game") }, "▶ Start")
|
label.className = "state-label " + s.state;
|
||||||
: h("button", { class: "btn", type: "button", onclick: () => serverAction("stop-game", { title: "Stop the game server?",
|
}
|
||||||
text: "All players are kicked and nobody can join until you start it again. The panel keeps running.", danger: true, confirm: "Stop" }) }, "■ Stop"),
|
const buttons = h("div", { class: "row" },
|
||||||
h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart the server?",
|
running || starting
|
||||||
text: "Settings (server.json), license and save game are loaded again. Players reconnect automatically. You stay logged in.", confirm: "Restart" }) }, "⟳ Restart"),
|
? h("button", { class: "btn", type: "button", disabled: starting, onclick: () => serverAction("stop", { title: "Stop the server?",
|
||||||
can("Owner") ? h("button", { class: "btn danger", type: "button", onclick: () => serverAction("shutdown", { title: "Shut down completely?",
|
text: "All players are disconnected and nobody can join until you start it again. The panel keeps running.", danger: true, confirm: "Stop" }) }, "■ Stop")
|
||||||
text: "The game server AND this panel stop. Start it again on the machine (e.g. docker compose up -d).", danger: true, confirm: "Shut down" }) }, "⏻ Shut down") : null);
|
: h("button", { class: "btn primary", type: "button", onclick: () => serverAction("start") }, "▶ Start"),
|
||||||
|
h("button", { class: "btn", type: "button", disabled: starting, onclick: () => serverAction("restart", { title: "Restart the server?",
|
||||||
|
text: "Settings (server.json), license and save game are loaded again. Players reconnect automatically.", confirm: "Restart" }) }, "⟳ Restart"));
|
||||||
|
|
||||||
|
// Warum läuft er nicht? Mit direktem Weg zur Lösung
|
||||||
|
let problem = null;
|
||||||
|
if (!running && s.lastError) {
|
||||||
|
const fix = s.hasLicenseKey === false ? h("button", { class: "btn small", type: "button", onclick: () => go("settings") }, "Enter license key")
|
||||||
|
: s.hasSave === false ? h("button", { class: "btn small", type: "button", onclick: () => go("world") }, "Upload save game")
|
||||||
|
: h("button", { class: "btn small ghost", type: "button", onclick: () => go("console") }, "Open console");
|
||||||
|
problem = h("div", { class: "notice " + (s.state === "error" ? "bad" : "") }, h("div", {}, h("b", {}, s.state === "error" ? "The server could not start" : "Note"), h("div", {}, s.lastError)), fix);
|
||||||
|
}
|
||||||
|
el.replaceChildren(buttons, problem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Einstellungen
|
// ------------------------------------------------------------------ Einstellungen
|
||||||
@@ -355,7 +376,7 @@ function renderSettings(page) {
|
|||||||
secret("licenseKey", "License key", c.licenseKeySet),
|
secret("licenseKey", "License key", c.licenseKeySet),
|
||||||
secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet))),
|
secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet))),
|
||||||
h("div", { class: "row" }, h("button", { class: "btn primary", type: "button", onclick: save }, "Save settings"),
|
h("div", { class: "row" }, h("button", { class: "btn primary", type: "button", onclick: save }, "Save settings"),
|
||||||
h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart the server now?",
|
h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart / start the server now?",
|
||||||
text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply")));
|
text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-75
@@ -60,9 +60,10 @@ if (fromEnvironment.Count > 0)
|
|||||||
if (created && string.IsNullOrWhiteSpace(config.LicenseKey))
|
if (created && string.IsNullOrWhiteSpace(config.LicenseKey))
|
||||||
{
|
{
|
||||||
GameServer.Log("Created " + configPath);
|
GameServer.Log("Created " + configPath);
|
||||||
GameServer.Log("Get a license key at " + MasterServer.Url + "/keys and put it into server.json (\"LicenseKey\").");
|
GameServer.Log("Get a license key at " + MasterServer.Url + "/keys" +
|
||||||
Pause();
|
(config.PanelEnabled ? " and enter it in the web panel (Settings) - or put it into server.json (\"LicenseKey\")."
|
||||||
return 1;
|
: " and put it into server.json (\"LicenseKey\")."));
|
||||||
|
if (!config.PanelEnabled) { Pause(); return 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --import-save [Pfad]: Spielstand aus My Summer Car übernehmen
|
// --import-save [Pfad]: Spielstand aus My Summer Car übernehmen
|
||||||
@@ -94,12 +95,9 @@ if (Array.IndexOf(args, "--import-save") >= 0)
|
|||||||
if (Directory.Exists(original)) Directory.Delete(original, true);
|
if (Directory.Exists(original)) Directory.Delete(original, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Konsole, "msco <befehl>" (docker exec) und Signale gehen immer an den gerade laufenden Server -
|
// Der Host läuft die ganze Zeit (Port + Web-Panel) und startet/stoppt den Gameserver - auch wenn der nicht starten
|
||||||
// nach einem Neustart aus dem Panel ist das ein neuer. Das Web-Panel bleibt dabei bestehen (man bleibt angemeldet).
|
// kann (Lizenz, Spielstand ...), bleibt das Panel erreichbar. Konsole, "msco <befehl>" und Signale gehen an den Host.
|
||||||
GameServer server = null;
|
var host = new ServerHost(config, dataDir, configPath);
|
||||||
AdminPanel panel = null;
|
|
||||||
Action<string> send = line => server?.EnqueueCommand(line);
|
|
||||||
Action stopRequested = () => { if (server == null) Environment.Exit(0); else server.EnqueueCommand("stop"); };
|
|
||||||
|
|
||||||
// Konsolenbefehle in eigenem Thread lesen, ausgeführt werden sie in der Server-Schleife.
|
// 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.
|
// Als Dienst (systemd/Docker ohne Terminal) gibt es keine Eingabe - dann einfach ohne Konsole laufen.
|
||||||
@@ -108,7 +106,7 @@ var input = new Thread(() =>
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
string line;
|
string line;
|
||||||
while ((line = Console.ReadLine()) != null) send(line);
|
while ((line = Console.ReadLine()) != null) host.Command(line);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}) { IsBackground = true };
|
}) { IsBackground = true };
|
||||||
@@ -126,7 +124,7 @@ var commandWatcher = new Thread(() =>
|
|||||||
if (!File.Exists(commandFile)) continue;
|
if (!File.Exists(commandFile)) continue;
|
||||||
File.Move(commandFile, processing, true);
|
File.Move(commandFile, processing, true);
|
||||||
foreach (string line in File.ReadAllLines(processing))
|
foreach (string line in File.ReadAllLines(processing))
|
||||||
if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); send(line.Trim()); }
|
if (line.Trim().Length > 0) { GameServer.Log("> " + line.Trim()); host.Command(line.Trim()); }
|
||||||
File.Delete(processing);
|
File.Delete(processing);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
@@ -135,71 +133,13 @@ var commandWatcher = new Thread(() =>
|
|||||||
commandWatcher.Start();
|
commandWatcher.Start();
|
||||||
|
|
||||||
// Sauber beenden (Weltzustand speichern) bei Strg+C, systemctl stop / docker stop (SIGTERM) und SIGQUIT
|
// Sauber beenden (Weltzustand speichern) bei Strg+C, systemctl stop / docker stop (SIGTERM) und SIGQUIT
|
||||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopRequested(); };
|
Console.CancelKeyPress += (_, e) => { e.Cancel = true; host.RequestExit(); };
|
||||||
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; stopRequested(); });
|
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; host.RequestExit(); });
|
||||||
using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; stopRequested(); });
|
using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; host.RequestExit(); });
|
||||||
|
|
||||||
for (bool restarting = false; ; restarting = true)
|
int exitCode = host.Run();
|
||||||
{
|
if (exitCode != 0) Pause();
|
||||||
if (restarting)
|
return exitCode;
|
||||||
{
|
|
||||||
// Neustart: server.json neu lesen (im Panel geänderte Einstellungen), Lizenz und Spielstand neu prüfen
|
|
||||||
try { config = ServerConfig.LoadOrCreate(configPath, out _); }
|
|
||||||
catch (Exception e) { GameServer.Log("Cannot read " + configPath + " (" + e.Message + ") - keeping the previous settings"); }
|
|
||||||
config.ApplyEnvironment();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lizenzschlüssel beim Keymaster prüfen
|
|
||||||
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 next = new GameServer(config, dataDir, configPath, key.Owner);
|
|
||||||
if (!next.LoadSave())
|
|
||||||
{
|
|
||||||
Pause();
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
// Web-Panel (wie txAdmin) - standardmäßig auf dem Spiel-Port; bei einem Neustart dasselbe Panel weiterverwenden
|
|
||||||
if (config.PanelEnabled || panel != null)
|
|
||||||
{
|
|
||||||
if (panel == null) panel = AdminPanel.Start(next, config, dataDir);
|
|
||||||
else panel.Attach(next, config);
|
|
||||||
next.Panel = panel;
|
|
||||||
}
|
|
||||||
server = next;
|
|
||||||
|
|
||||||
server.Run();
|
|
||||||
if (!server.RestartRequested) break;
|
|
||||||
server = null;
|
|
||||||
GameServer.Log("Restarting the server ...");
|
|
||||||
}
|
|
||||||
panel?.Stop();
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
string ArgValue(string name)
|
string ArgValue(string name)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms
|
|||||||
|
|
||||||
| Bereich | Was geht |
|
| Bereich | Was geht |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Dashboard | Spieler, Laufzeit, Weltzustand, Backups – **Start / Stop / Restart / Shut down** |
|
| Dashboard | Spieler, Laufzeit, Weltzustand, Backups – **Start / Stop / Restart** des Gameservers, Grund wenn er nicht läuft |
|
||||||
| Players | Liste mit ID, Name, SteamID, IP · Kick, Ban, Admin geben/nehmen · Nachricht an alle |
|
| Players | Liste mit ID, Name, SteamID, IP · Kick, Ban, Admin geben/nehmen · Nachricht an alle |
|
||||||
| Console | Live-Log (Chat, Beitritte, Admin-Aktionen) und Befehle eingeben (mit Verlauf ↑/↓) |
|
| Console | Live-Log (Chat, Beitritte, Admin-Aktionen) und Befehle eingeben (mit Verlauf ↑/↓) |
|
||||||
| Bans | Gebannte ansehen, entbannen, SteamID auch offline bannen |
|
| Bans | Gebannte ansehen, entbannen, SteamID auch offline bannen |
|
||||||
@@ -144,9 +144,13 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms
|
|||||||
| Settings | `server.json` bearbeiten (Name, Spielerzahl, Sichtbarkeit, Ports, Panel, Schlüssel …) |
|
| Settings | `server.json` bearbeiten (Name, Spielerzahl, Sichtbarkeit, Ports, Panel, Schlüssel …) |
|
||||||
| Panel Users | weitere Benutzer anlegen: **Moderator** (Spieler/Chat), **Admin** (alles am Server), **Owner** (+ Benutzer) |
|
| Panel Users | weitere Benutzer anlegen: **Moderator** (Spieler/Chat), **Admin** (alles am Server), **Owner** (+ Benutzer) |
|
||||||
|
|
||||||
- **Stop** nimmt nur das Spiel offline (Spieler raus, nicht gelistet) – das Panel läuft weiter, **Start** holt es
|
- **Das Panel ist unabhängig vom Gameserver:** Es läuft auch, wenn der Server gestoppt ist, abgestürzt ist oder gar
|
||||||
zurück. **Restart** lädt Einstellungen, Lizenz und Spielstand neu (man bleibt angemeldet). **Shut down** beendet
|
nicht starten kann (z.B. kein Lizenzschlüssel, kein Spielstand). Das Dashboard zeigt dann den Grund – Schlüssel
|
||||||
alles.
|
unter **Settings** eintragen bzw. Spielstand als ZIP unter **World & Backups** hochladen und **Start** drücken.
|
||||||
|
- **Stop** fährt den Gameserver herunter (Spieler bekommen den Grund, niemand kann beitreten), **Start** startet ihn
|
||||||
|
wieder, **Restart** lädt Einstellungen, Lizenz und Spielstand neu. Nach einem Absturz startet er automatisch neu.
|
||||||
|
- Erster Start ganz ohne Einrichtung: `docker compose up -d`, Panel öffnen, Einrichtungs-Code aus dem Log eingeben,
|
||||||
|
Lizenzschlüssel und Spielstand im Panel hinterlegen, Start.
|
||||||
- Passwörter werden nur als PBKDF2-Hash in `data/panel_users.json` gespeichert, Login mit Sperre nach
|
- Passwörter werden nur als PBKDF2-Hash in `data/panel_users.json` gespeichert, Login mit Sperre nach
|
||||||
Fehlversuchen, Sitzungs-Cookie (HttpOnly, SameSite=Strict), Schutz gegen CSRF.
|
Fehlversuchen, Sitzungs-Cookie (HttpOnly, SameSite=Strict), Schutz gegen CSRF.
|
||||||
|
|
||||||
@@ -179,8 +183,9 @@ erreichbar. Ganz aus: `"PanelEnabled": false`.
|
|||||||
| `backup` | aktuellen Spielstand jetzt sichern |
|
| `backup` | aktuellen Spielstand jetzt sichern |
|
||||||
| `reset-world` | ganze Welt zurück auf den Reset-Punkt (den Spielstand, wie er dem Server gegeben wurde: Start, `--import-save`, `reload-save`) – alle verbinden neu |
|
| `reset-world` | ganze Welt zurück auf den Reset-Punkt (den Spielstand, wie er dem Server gegeben wurde: Start, `--import-save`, `reload-save`) – alle verbinden neu |
|
||||||
| `set-reset-point` | aktuellen Spielstand als neuen Reset-Punkt nehmen |
|
| `set-reset-point` | aktuellen Spielstand als neuen Reset-Punkt nehmen |
|
||||||
| `stop-game` / `start-game` | Spiel offline nehmen (Spieler raus, Panel läuft weiter) / wieder starten |
|
| `stop` / `start` | Gameserver stoppen (das Web-Panel läuft weiter) / wieder starten |
|
||||||
| `restart` | Server neu starten (Einstellungen, Lizenz, Spielstand neu laden) |
|
| `restart` | Server neu starten (Einstellungen, Lizenz, Spielstand neu laden) |
|
||||||
|
| `exit` | ganzes Programm beenden (auch das Panel) – mit Docker startet es danach wieder, dauerhaft aus: `docker compose stop` |
|
||||||
| `panel` | Adresse des Web-Panels und ggf. den Einrichtungs-Code anzeigen |
|
| `panel` | Adresse des Web-Panels und ggf. den Einrichtungs-Code anzeigen |
|
||||||
| `stop` | speichern und beenden |
|
| `stop` | speichern und beenden |
|
||||||
|
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spielstand-Dateien im Datenordner: data/save (aktiv), data/save_original (Reset-Punkt), data/save_backups/Zeitstempel.
|
||||||
|
/// Wird vom laufenden Server benutzt und vom Host/Panel, wenn der Server aus ist (z.B. Spielstand hochladen, bevor er
|
||||||
|
/// überhaupt starten kann).
|
||||||
|
/// </summary>
|
||||||
|
public static class SaveStore
|
||||||
|
{
|
||||||
|
public static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" };
|
||||||
|
public const int KeepBackups = 5;
|
||||||
|
public const long MaxZipBytes = 64L * 1024 * 1024;
|
||||||
|
|
||||||
|
public static string SavePath(string dataDir) => Path.Combine(dataDir, "save");
|
||||||
|
public static string OriginalPath(string dataDir) => Path.Combine(dataDir, "save_original");
|
||||||
|
public static string BackupsPath(string dataDir) => Path.Combine(dataDir, "save_backups");
|
||||||
|
public static string WorldPath(string dataDir) => Path.Combine(dataDir, "world.json");
|
||||||
|
|
||||||
|
public static bool HasSave(string dataDir) => File.Exists(Path.Combine(SavePath(dataDir), "defaultES2File.txt"));
|
||||||
|
public static bool HasResetPoint(string dataDir) => File.Exists(Path.Combine(OriginalPath(dataDir), "defaultES2File.txt"));
|
||||||
|
|
||||||
|
/// <summary>Backup-Ordner, neueste zuerst.</summary>
|
||||||
|
public static List<string> BackupDirs(string dataDir)
|
||||||
|
{
|
||||||
|
string backups = BackupsPath(dataDir);
|
||||||
|
return Directory.Exists(backups) ? Directory.GetDirectories(backups).OrderByDescending(d => d).ToList() : new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aktuellen Spielstand nach save_backups/Zeitstempel kopieren, die letzten Stände behalten.</summary>
|
||||||
|
public static void Backup(string dataDir)
|
||||||
|
{
|
||||||
|
string save = SavePath(dataDir);
|
||||||
|
if (!Directory.Exists(save)) return;
|
||||||
|
string backups = BackupsPath(dataDir);
|
||||||
|
string target = Path.Combine(backups, DateTime.Now.ToString("yyyyMMdd-HHmmss"));
|
||||||
|
Directory.CreateDirectory(target);
|
||||||
|
foreach (string file in Directory.GetFiles(save))
|
||||||
|
if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true);
|
||||||
|
foreach (string old in Directory.GetDirectories(backups).OrderByDescending(d => d).Skip(KeepBackups))
|
||||||
|
try { Directory.Delete(old, true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aktuellen Spielstand als Reset-Punkt merken. Gibt einen Fehler zurück oder null.</summary>
|
||||||
|
public static string SetResetPoint(string dataDir)
|
||||||
|
{
|
||||||
|
if (!HasSave(dataDir)) return "No save game to use as reset point";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string original = OriginalPath(dataDir);
|
||||||
|
if (Directory.Exists(original)) Directory.Delete(original, true);
|
||||||
|
Directory.CreateDirectory(original);
|
||||||
|
foreach (string file in Directory.GetFiles(SavePath(dataDir)))
|
||||||
|
if (!file.EndsWith(".tmp")) File.Copy(file, Path.Combine(original, Path.GetFileName(file)), true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception e) { return "Could not remember the reset point: " + e.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dateien in den aktiven Spielstand schreiben; bekannte Dateien, die fehlen, werden gelöscht.</summary>
|
||||||
|
public static void WriteSave(string dataDir, IReadOnlyDictionary<string, byte[]> files)
|
||||||
|
{
|
||||||
|
string save = SavePath(dataDir);
|
||||||
|
Directory.CreateDirectory(save);
|
||||||
|
foreach (string name in SaveFiles)
|
||||||
|
{
|
||||||
|
string target = Path.Combine(save, name);
|
||||||
|
if (files.TryGetValue(name, out var data))
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(target + ".tmp", data);
|
||||||
|
File.Move(target + ".tmp", target, true);
|
||||||
|
}
|
||||||
|
else if (File.Exists(target)) File.Delete(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dateien eines Ordners (Backup, Reset-Punkt) lesen - nur die bekannten Spielstand-Dateien.</summary>
|
||||||
|
public static Dictionary<string, byte[]> ReadFolder(string folder)
|
||||||
|
{
|
||||||
|
var files = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (string name in SaveFiles)
|
||||||
|
{
|
||||||
|
string file = Path.Combine(folder, name);
|
||||||
|
if (File.Exists(file)) files[name] = File.ReadAllBytes(file);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Backup per Nummer (1 = neuestes) oder Ordnername finden.</summary>
|
||||||
|
public static string FindBackup(string dataDir, string arg)
|
||||||
|
{
|
||||||
|
var list = BackupDirs(dataDir);
|
||||||
|
arg = (arg ?? "").Trim();
|
||||||
|
return int.TryParse(arg, out int n) && n >= 1 && n <= list.Count ? list[n - 1]
|
||||||
|
: list.FirstOrDefault(d => Path.GetFileName(d) == arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spielstand aus einer ZIP lesen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem Ordner
|
||||||
|
/// der ZIP) - Pfade aus der ZIP werden nie benutzt. Gibt einen Fehler zurück oder null.
|
||||||
|
/// </summary>
|
||||||
|
public static string ExtractZip(byte[] zip, out Dictionary<string, byte[]> found)
|
||||||
|
{
|
||||||
|
found = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||||
|
long total = 0;
|
||||||
|
foreach (var entry in archive.Entries)
|
||||||
|
{
|
||||||
|
string file = Path.GetFileName(entry.FullName.Replace('\\', '/'));
|
||||||
|
string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (known == null) continue;
|
||||||
|
if (entry.Length > MaxZipBytes || (total += entry.Length) > MaxZipBytes) return "The save game is too large";
|
||||||
|
using var s = entry.Open();
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
s.CopyTo(ms);
|
||||||
|
found[known] = ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InvalidDataException) { return "This is not a valid ZIP file"; }
|
||||||
|
if (!found.ContainsKey("defaultES2File.txt"))
|
||||||
|
return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aktuellen Spielstand als ZIP.</summary>
|
||||||
|
public static byte[] ExportZip(string dataDir)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
|
||||||
|
{
|
||||||
|
foreach (string name in SaveFiles)
|
||||||
|
{
|
||||||
|
string file = Path.Combine(SavePath(dataDir), name);
|
||||||
|
if (File.Exists(file)) archive.CreateEntryFromFile(file, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Wenn der Server aus ist (Host/Panel)
|
||||||
|
|
||||||
|
/// <summary>Server aus: ZIP als neuen Spielstand übernehmen (Backup vorher, neuer Reset-Punkt, Weltzustand leer).</summary>
|
||||||
|
public static string ImportZipOffline(string dataDir, byte[] zip, string by)
|
||||||
|
{
|
||||||
|
string error = ExtractZip(zip, out var files);
|
||||||
|
if (error != null) return error;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Backup(dataDir);
|
||||||
|
WriteSave(dataDir, files);
|
||||||
|
SetResetPoint(dataDir);
|
||||||
|
ResetWorldFile(dataDir, full: true);
|
||||||
|
}
|
||||||
|
catch (Exception e) { return "Could not write the save game: " + e.Message; }
|
||||||
|
GameServer.Log($"New save game uploaded by {by} ({files.Count} files, server stopped) - it is also the new reset point");
|
||||||
|
return $"Save game uploaded ({files.Count} files) - start the server to use it";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server aus: Backup wiederherstellen.</summary>
|
||||||
|
public static string RestoreOffline(string dataDir, string arg, string by)
|
||||||
|
{
|
||||||
|
string chosen = FindBackup(dataDir, arg);
|
||||||
|
if (chosen == null) return "Backup not found";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var files = ReadFolder(chosen); // erst lesen - Backup() unten könnte alte Backups löschen
|
||||||
|
Backup(dataDir);
|
||||||
|
WriteSave(dataDir, files);
|
||||||
|
ResetWorldFile(dataDir, full: false);
|
||||||
|
}
|
||||||
|
catch (Exception e) { return "Could not restore: " + e.Message; }
|
||||||
|
GameServer.Log($"Restored save game {Path.GetFileName(chosen)} ({by}, server stopped)");
|
||||||
|
return "Restored " + Path.GetFileName(chosen) + " - start the server to use it";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server aus: Welt komplett auf den Reset-Punkt zurück.</summary>
|
||||||
|
public static string ResetOffline(string dataDir, string by)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (HasResetPoint(dataDir))
|
||||||
|
{
|
||||||
|
var files = ReadFolder(OriginalPath(dataDir));
|
||||||
|
Backup(dataDir);
|
||||||
|
WriteSave(dataDir, files);
|
||||||
|
}
|
||||||
|
ResetWorldFile(dataDir, full: true);
|
||||||
|
}
|
||||||
|
catch (Exception e) { return "Could not reset: " + e.Message; }
|
||||||
|
GameServer.Log($"World reset by {by} (server stopped) - back to the reset point");
|
||||||
|
return "World reset to the reset point";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>world.json anpassen: full = alles weg, sonst nur was im neuen Spielstand steckt.</summary>
|
||||||
|
static void ResetWorldFile(string dataDir, bool full)
|
||||||
|
{
|
||||||
|
string path = WorldPath(dataDir);
|
||||||
|
var world = WorldState.Load(path);
|
||||||
|
if (full) world.Reset(); else world.ResetForNewSave();
|
||||||
|
world.Save(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
+401
@@ -0,0 +1,401 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>Zustand des Gameservers aus Sicht des Hosts.</summary>
|
||||||
|
public enum HostState { Stopped, Starting, Running, Error }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host (wie txAdmin): läuft die ganze Zeit, besitzt den Port und das Web-Panel und startet/stoppt den Gameserver.
|
||||||
|
/// Ist der Gameserver aus (gestoppt, abgestürzt, fehlender Lizenzschlüssel oder Spielstand ...), bleibt das Panel
|
||||||
|
/// erreichbar - dort lässt sich alles einrichten und der Server wieder starten.
|
||||||
|
/// Verbindungen auf dem Spiel-Port: Browser -> Panel, Spiel -> laufender Server bzw. "gerade offline".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerHost
|
||||||
|
{
|
||||||
|
const int MaxCrashRestarts = 3;
|
||||||
|
static readonly TimeSpan CrashWindow = TimeSpan.FromMinutes(10);
|
||||||
|
static readonly TimeSpan CrashRestartDelay = TimeSpan.FromSeconds(10);
|
||||||
|
static readonly TimeSpan MasterRetryDelay = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
readonly string dataDir, configPath;
|
||||||
|
readonly bool offline;
|
||||||
|
readonly ConcurrentQueue<string> commands = new();
|
||||||
|
readonly AutoResetEvent wake = new(false);
|
||||||
|
readonly List<DateTime> crashes = new();
|
||||||
|
|
||||||
|
TcpListener listener;
|
||||||
|
int listenerPort;
|
||||||
|
volatile bool exitRequested;
|
||||||
|
volatile bool startRequested = true; // beim Programmstart automatisch starten
|
||||||
|
DateTime startAt = DateTime.MinValue;
|
||||||
|
|
||||||
|
public ServerConfig Config { get; private set; }
|
||||||
|
public GameServer Server { get; private set; }
|
||||||
|
public AdminPanel Panel { get; private set; }
|
||||||
|
public HostState State { get; private set; } = HostState.Stopped;
|
||||||
|
public string LastError { get; private set; }
|
||||||
|
public DateTime StateSince { get; private set; } = DateTime.UtcNow;
|
||||||
|
public string DataDir => dataDir;
|
||||||
|
public string ConfigPath => configPath;
|
||||||
|
/// <summary>Für Tests: Einstellungen am frisch erzeugten Server vornehmen (z.B. kürzere Zeitlimits).</summary>
|
||||||
|
public Action<GameServer> ConfigureServer { get; set; }
|
||||||
|
|
||||||
|
public ServerHost(ServerConfig config, string dataDir, string configPath, bool offline = false)
|
||||||
|
{
|
||||||
|
Config = config;
|
||||||
|
this.dataDir = dataDir;
|
||||||
|
this.configPath = configPath;
|
||||||
|
this.offline = offline;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Steuerung (Konsole, Panel, Signale)
|
||||||
|
|
||||||
|
/// <summary>Konsolenzeile: bei laufendem Server an ihn, sonst Host-Befehle (start, exit, status, panel).</summary>
|
||||||
|
public void Command(string line)
|
||||||
|
{
|
||||||
|
string first = line.Trim().TrimStart('').Split(' ')[0].ToLowerInvariant();
|
||||||
|
if (first == "exit" || first == "quit") { RequestExit(); return; }
|
||||||
|
var server = Server;
|
||||||
|
if (server != null && State == HostState.Running) { server.EnqueueCommand(line); return; }
|
||||||
|
commands.Enqueue(line);
|
||||||
|
wake.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RequestStart(string by)
|
||||||
|
{
|
||||||
|
if (State == HostState.Running || State == HostState.Starting) return "The server is already " + State.ToString().ToLower();
|
||||||
|
GameServer.Log($"Start requested by {by}");
|
||||||
|
crashes.Clear();
|
||||||
|
startAt = DateTime.MinValue;
|
||||||
|
startRequested = true;
|
||||||
|
wake.Set();
|
||||||
|
return "Starting the server";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RequestStop(string by)
|
||||||
|
{
|
||||||
|
var server = Server;
|
||||||
|
if (server == null || State != HostState.Running)
|
||||||
|
{
|
||||||
|
startRequested = false; // z.B. geplanter Neustart nach Absturz
|
||||||
|
return "The server is not running";
|
||||||
|
}
|
||||||
|
server.Invoke(() => server.PanelServerAction("stop", by));
|
||||||
|
return "Stopping the server - the panel keeps running";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RequestRestart(string by)
|
||||||
|
{
|
||||||
|
var server = Server;
|
||||||
|
if (server == null || State != HostState.Running) return RequestStart(by);
|
||||||
|
server.Invoke(() => server.PanelServerAction("restart", by));
|
||||||
|
return "Restarting - the server is back in a few seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ganzes Programm beenden (Strg+C, docker stop, "exit").</summary>
|
||||||
|
public void RequestExit()
|
||||||
|
{
|
||||||
|
exitRequested = true;
|
||||||
|
Server?.RequestExit();
|
||||||
|
wake.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Hauptschleife
|
||||||
|
|
||||||
|
public int Run()
|
||||||
|
{
|
||||||
|
if (Config.PanelEnabled) Panel = AdminPanel.Start(this, Config, dataDir);
|
||||||
|
if (!Listen(Config.Port)) return 1;
|
||||||
|
if (Panel == null)
|
||||||
|
GameServer.Log("Web panel is disabled (PanelEnabled in server.json)");
|
||||||
|
|
||||||
|
while (!exitRequested)
|
||||||
|
{
|
||||||
|
if (!startRequested || DateTime.UtcNow < startAt)
|
||||||
|
{
|
||||||
|
HandleHostCommands();
|
||||||
|
wake.WaitOne(200);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
startRequested = false;
|
||||||
|
|
||||||
|
var server = TryCreateServer();
|
||||||
|
if (server == null)
|
||||||
|
{
|
||||||
|
// Ohne Panel gibt es niemanden, der es reparieren könnte: dann wie früher beenden
|
||||||
|
if (Panel == null) return 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Server = server;
|
||||||
|
SetState(HostState.Running, null);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
server.Run();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
GameServer.Log("THE SERVER CRASHED: " + e);
|
||||||
|
OnCrash(e.Message);
|
||||||
|
Server = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Server = null;
|
||||||
|
|
||||||
|
if (server.ExitRequested) break;
|
||||||
|
if (server.RestartRequested)
|
||||||
|
{
|
||||||
|
GameServer.Log("Restarting the server ...");
|
||||||
|
startRequested = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SetState(HostState.Stopped, null);
|
||||||
|
if (Panel == null) break; // ohne Panel: Stopp = Programmende
|
||||||
|
GameServer.Log("The game server is stopped. The web panel keeps running - start it there or type 'start' ('exit' to quit).");
|
||||||
|
}
|
||||||
|
|
||||||
|
try { listener?.Stop(); } catch { }
|
||||||
|
Panel?.Stop();
|
||||||
|
GameServer.Log("Bye.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleHostCommands()
|
||||||
|
{
|
||||||
|
while (commands.TryDequeue(out string line))
|
||||||
|
{
|
||||||
|
string cmd = line.Trim().TrimStart('');
|
||||||
|
if (cmd.Length == 0) continue;
|
||||||
|
var server = Server;
|
||||||
|
if (server != null && State == HostState.Running)
|
||||||
|
{
|
||||||
|
server.EnqueueCommand(cmd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string first = cmd.Split(' ')[0].ToLowerInvariant();
|
||||||
|
switch (first)
|
||||||
|
{
|
||||||
|
case "start":
|
||||||
|
case "restart":
|
||||||
|
GameServer.Log(RequestStart("Console"));
|
||||||
|
break;
|
||||||
|
case "exit":
|
||||||
|
case "quit":
|
||||||
|
RequestExit();
|
||||||
|
break;
|
||||||
|
case "stop":
|
||||||
|
startRequested = false;
|
||||||
|
GameServer.Log("The server is not running ('exit' quits the program)");
|
||||||
|
break;
|
||||||
|
case "panel":
|
||||||
|
if (Panel != null) Panel.PrintInfo(); else GameServer.Log("The web panel is disabled");
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
GameServer.Log($"Server {State.ToString().ToLower()}" + (LastError != null ? ": " + LastError : ""));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
GameServer.Log("The game server is " + State.ToString().ToLower() + " - type 'start' (or 'exit' to quit)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GameServer TryCreateServer()
|
||||||
|
{
|
||||||
|
SetState(HostState.Starting, null);
|
||||||
|
if (!offline)
|
||||||
|
{
|
||||||
|
// Einstellungen neu lesen (im Panel geändert), Umgebungsvariablen gelten weiter
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fresh = ServerConfig.LoadOrCreate(configPath, out _);
|
||||||
|
fresh.ApplyEnvironment();
|
||||||
|
Config = fresh;
|
||||||
|
Panel?.UpdateConfig(fresh);
|
||||||
|
}
|
||||||
|
catch (Exception e) { GameServer.Log("Cannot read " + configPath + " (" + e.Message + ") - using the previous settings"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
string owner = "Test";
|
||||||
|
if (!offline)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Config.LicenseKey))
|
||||||
|
return Fail("No license key set - get one at " + MasterServer.Url + "/keys and enter it in the panel (Settings) or server.json");
|
||||||
|
GameServer.Log($"Checking license key at {MasterServer.Url} ...");
|
||||||
|
var key = new MasterClient(MasterServer.Url).ValidateKeyAsync(Config.LicenseKey).GetAwaiter().GetResult();
|
||||||
|
if (key.Unreachable)
|
||||||
|
{
|
||||||
|
// Master gerade nicht erreichbar: gleich nochmal versuchen, bis der Server gestoppt wird
|
||||||
|
startRequested = true;
|
||||||
|
startAt = DateTime.UtcNow + MasterRetryDelay;
|
||||||
|
return Fail($"Master server not reachable ({key.Error}) - retrying in {MasterRetryDelay.TotalSeconds:0} s");
|
||||||
|
}
|
||||||
|
if (!key.Valid)
|
||||||
|
return Fail("License key invalid: " + (key.Error ?? "unknown key") + " - manage your keys at " + MasterServer.Url + "/keys");
|
||||||
|
GameServer.Log($"License key OK ({key.Label}, owner {key.Owner})");
|
||||||
|
owner = key.Owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameServer server;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
server = new GameServer(Config, dataDir, configPath, owner, offline);
|
||||||
|
}
|
||||||
|
catch (Exception e) { return Fail("Could not create the server: " + e.Message); }
|
||||||
|
if (!server.LoadSave())
|
||||||
|
return Fail(SaveStore.HasSave(dataDir)
|
||||||
|
? "The save game could not be loaded - see the console"
|
||||||
|
: "No save game yet - upload one as ZIP in the panel (World & Backups) or copy it to data/save");
|
||||||
|
server.Panel = Panel;
|
||||||
|
server.Host = this;
|
||||||
|
ConfigureServer?.Invoke(server);
|
||||||
|
if (Config.Port != listenerPort) Listen(Config.Port); // Port in den Einstellungen geändert
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameServer Fail(string reason)
|
||||||
|
{
|
||||||
|
GameServer.Log("Server not started: " + reason);
|
||||||
|
SetState(HostState.Error, reason);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnCrash(string message)
|
||||||
|
{
|
||||||
|
SetState(HostState.Error, "Crashed: " + message);
|
||||||
|
crashes.Add(DateTime.UtcNow);
|
||||||
|
crashes.RemoveAll(t => DateTime.UtcNow - t > CrashWindow);
|
||||||
|
if (crashes.Count > MaxCrashRestarts)
|
||||||
|
{
|
||||||
|
GameServer.Log($"The server crashed {crashes.Count} times in {CrashWindow.TotalMinutes:0} minutes - not restarting automatically. Start it in the panel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GameServer.Log($"Restarting in {CrashRestartDelay.TotalSeconds:0} s ...");
|
||||||
|
startRequested = true;
|
||||||
|
startAt = DateTime.UtcNow + CrashRestartDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetState(HostState state, string error)
|
||||||
|
{
|
||||||
|
State = state;
|
||||||
|
LastError = error;
|
||||||
|
StateSince = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kurzstatus fürs Panel, wenn der Server nicht läuft (gleiche Felder wie GameServer.PanelStatus).</summary>
|
||||||
|
public object OfflineStatus() => new
|
||||||
|
{
|
||||||
|
state = State.ToString().ToLowerInvariant(),
|
||||||
|
lastError = LastError,
|
||||||
|
stateSince = StateSince.ToString("o"),
|
||||||
|
name = Config.Name,
|
||||||
|
description = Config.Description,
|
||||||
|
version = GameServer.ModVersion,
|
||||||
|
players = 0,
|
||||||
|
maxPlayers = Config.MaxPlayers,
|
||||||
|
uptimeSeconds = 0L,
|
||||||
|
port = Config.Port,
|
||||||
|
isPublic = Config.Public,
|
||||||
|
requireSteam = Config.RequireSteam,
|
||||||
|
steamVerification = !string.IsNullOrWhiteSpace(Config.SteamWebApiKey),
|
||||||
|
objects = 0,
|
||||||
|
actions = 0,
|
||||||
|
boughtItems = 0,
|
||||||
|
admins = Config.Admins.Count,
|
||||||
|
bans = Config.BannedSteamIds.Count,
|
||||||
|
backups = SaveStore.BackupDirs(dataDir).Count,
|
||||||
|
resetPoint = SaveStore.HasResetPoint(dataDir)
|
||||||
|
? File.GetLastWriteTimeUtc(Path.Combine(SaveStore.OriginalPath(dataDir), "defaultES2File.txt")).ToString("o") : null,
|
||||||
|
saveSyncMinutes = Config.SaveSyncMinutes,
|
||||||
|
hasSave = SaveStore.HasSave(dataDir),
|
||||||
|
hasLicenseKey = !string.IsNullOrWhiteSpace(Config.LicenseKey)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Port
|
||||||
|
|
||||||
|
bool Listen(int port)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var next = new TcpListener(IPAddress.Any, port);
|
||||||
|
next.Start();
|
||||||
|
try { listener?.Stop(); } catch { }
|
||||||
|
listener = next;
|
||||||
|
listenerPort = port;
|
||||||
|
GameServer.Log($"Listening on port {port}" + (Panel != null && Panel.SharesGamePort ? " (game and web panel)" : ""));
|
||||||
|
_ = AcceptLoopAsync(next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
GameServer.Log($"Cannot listen on port {port}: {e.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task AcceptLoopAsync(TcpListener own)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
TcpClient tcp;
|
||||||
|
try { tcp = await own.AcceptTcpClientAsync(); }
|
||||||
|
catch { return; } // Listener gestoppt (Programmende oder Portwechsel)
|
||||||
|
_ = HandleConnectionAsync(tcp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In die ersten Bytes schauen (ohne sie zu verbrauchen): Browser ("GET /", "POST" ...) -> Panel,
|
||||||
|
/// sonst Spiel (Pakete beginnen mit einer kleinen Längenangabe). Beide Seiten reden zuerst.
|
||||||
|
/// </summary>
|
||||||
|
async Task HandleConnectionAsync(TcpClient tcp)
|
||||||
|
{
|
||||||
|
var buffer = new byte[4];
|
||||||
|
int n = 0;
|
||||||
|
if (Panel != null && Panel.SharesGamePort)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
n = await tcp.Client.ReceiveAsync(buffer, SocketFlags.Peek, timeout.Token);
|
||||||
|
if (n == 0) { tcp.Close(); return; }
|
||||||
|
if (n >= 4 || buffer[0] < 'A' || buffer[0] > 'Z') break; // kein Buchstabe = sicher das Spiel
|
||||||
|
await Task.Delay(15, timeout.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
try { tcp.Close(); } catch { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (AdminPanel.LooksLikeHttp(buffer, n))
|
||||||
|
{
|
||||||
|
await Panel.RelayAsync(tcp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var server = Server;
|
||||||
|
if (server != null && State == HostState.Running)
|
||||||
|
{
|
||||||
|
server.AcceptExternal(tcp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Server aus: dem Spiel sagen, warum (statt einer toten Verbindung)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tcp.NoDelay = true;
|
||||||
|
var frame = Wire.Frame(MsgType.Reject, Wire.Build(w => w.Write("The server is offline at the moment - try again later")));
|
||||||
|
await tcp.GetStream().WriteAsync(frame);
|
||||||
|
tcp.Client.Shutdown(SocketShutdown.Send);
|
||||||
|
await Task.Delay(1000);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
try { tcp.Close(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-29
@@ -15,14 +15,17 @@ string configPath = Path.Combine(dir, "server.json");
|
|||||||
var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } };
|
var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } };
|
||||||
config.Save(configPath);
|
config.Save(configPath);
|
||||||
|
|
||||||
var server = new GameServer(config, dir, configPath, "Tester", offline: true);
|
// Host wie im echten Betrieb: besitzt Port + Web-Panel (gleicher Port wie das Spiel) und startet den Gameserver
|
||||||
server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
|
var host = new ServerHost(config, dir, configPath, offline: true)
|
||||||
if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; }
|
{
|
||||||
// Web-Panel auf demselben Port wie das Spiel (Standard)
|
ConfigureServer = s => s.SaveUploadTimeoutSeconds = 1 // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
|
||||||
server.Panel = AdminPanel.Start(server, config, dir);
|
};
|
||||||
var serverThread = new Thread(server.Run) { IsBackground = true };
|
var hostThread = new Thread(() => host.Run()) { IsBackground = true };
|
||||||
serverThread.Start();
|
hostThread.Start();
|
||||||
Thread.Sleep(500);
|
for (int i = 0; i < 100 && host.State != HostState.Running; i++) Thread.Sleep(50);
|
||||||
|
if (host.State != HostState.Running) { Console.WriteLine("FAIL: server did not start: " + host.LastError); return 1; }
|
||||||
|
GameServer Srv() => host.Server;
|
||||||
|
async Task WaitForState(HostState state) { for (int i = 0; i < 100 && host.State != state; i++) await Task.Delay(50); }
|
||||||
|
|
||||||
int failed = 0, passed = 0;
|
int failed = 0, passed = 0;
|
||||||
void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.WriteLine((ok ? " ok " : " FAIL ") + what); }
|
void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.WriteLine((ok ? " ok " : " FAIL ") + what); }
|
||||||
@@ -151,7 +154,7 @@ try
|
|||||||
var hungry = await TestClient.Join("Hungry", 0, null);
|
var hungry = await TestClient.Join("Hungry", 0, null);
|
||||||
hungry.Send(MsgType.Ready, Array.Empty<byte>());
|
hungry.Send(MsgType.Ready, Array.Empty<byte>());
|
||||||
await hungry.WaitFor(MsgType.WorldSnapshot);
|
await hungry.WaitFor(MsgType.WorldSnapshot);
|
||||||
server.EnqueueCommand("save"); // (Zeitfenster TrackPositionFrom abwarten)
|
Srv().EnqueueCommand("save"); // (Zeitfenster TrackPositionFrom abwarten)
|
||||||
await Task.Delay(8500);
|
await Task.Delay(8500);
|
||||||
hungry.Send(MsgType.PlayerNeeds, Wire.Build(w => { w.Write(2); w.Write("PlayerHunger"); w.Write(77f); w.Write("PlayerThirst"); w.Write(12.5f); }));
|
hungry.Send(MsgType.PlayerNeeds, Wire.Build(w => { w.Write(2); w.Write("PlayerHunger"); w.Write(77f); w.Write("PlayerThirst"); w.Write(12.5f); }));
|
||||||
await Task.Delay(300);
|
await Task.Delay(300);
|
||||||
@@ -197,11 +200,11 @@ try
|
|||||||
Check(await watcher.WaitFor(MsgType.SpawnItem, 800) == null, "Non-admin cannot spawn items (not relayed)");
|
Check(await watcher.WaitFor(MsgType.SpawnItem, 800) == null, "Non-admin cannot spawn items (not relayed)");
|
||||||
watcher.Send(MsgType.Ready, Array.Empty<byte>());
|
watcher.Send(MsgType.Ready, Array.Empty<byte>());
|
||||||
Check(Snapshot.Parse(await watcher.WaitFor(MsgType.WorldSnapshot)).Spawns == 0, "Non-admin spawn is not stored");
|
Check(Snapshot.Parse(await watcher.WaitFor(MsgType.WorldSnapshot)).Spawns == 0, "Non-admin spawn is not stored");
|
||||||
server.EnqueueCommand("ban " + noAdmin.Id + " test");
|
Srv().EnqueueCommand("ban " + noAdmin.Id + " test");
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
var bannedTry = await TestClient.Join("NoAdmin", 0, null, expectReject: true);
|
var bannedTry = await TestClient.Join("NoAdmin", 0, null, expectReject: true);
|
||||||
Check(bannedTry.RejectReason != null && bannedTry.RejectReason.Contains("banned"), "Banned player cannot join: " + bannedTry.RejectReason);
|
Check(bannedTry.RejectReason != null && bannedTry.RejectReason.Contains("banned"), "Banned player cannot join: " + bannedTry.RejectReason);
|
||||||
server.EnqueueCommand("unban name:noadmin");
|
Srv().EnqueueCommand("unban name:noadmin");
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
var unbanned = await TestClient.Join("NoAdmin", 0, null);
|
var unbanned = await TestClient.Join("NoAdmin", 0, null);
|
||||||
Check(unbanned.Welcome != null, "After unban the player can join again");
|
Check(unbanned.Welcome != null, "After unban the player can join again");
|
||||||
@@ -214,7 +217,7 @@ try
|
|||||||
var spamming = true;
|
var spamming = true;
|
||||||
var spamTask = Task.Run(async () => { while (spamming) { spammer.Send(MsgType.Player, PlayerPos(1f, 2f, 3f)); await Task.Delay(5); } });
|
var spamTask = Task.Run(async () => { while (spamming) { spammer.Send(MsgType.Player, PlayerPos(1f, 2f, 3f)); await Task.Delay(5); } });
|
||||||
await Task.Delay(300);
|
await Task.Delay(300);
|
||||||
server.EnqueueCommand("kick " + spammer.Id + " testing");
|
Srv().EnqueueCommand("kick " + spammer.Id + " testing");
|
||||||
var kickMsg = await spammer.WaitFor(MsgType.Reject, 3000);
|
var kickMsg = await spammer.WaitFor(MsgType.Reject, 3000);
|
||||||
spamming = false;
|
spamming = false;
|
||||||
await spamTask;
|
await spamTask;
|
||||||
@@ -225,7 +228,7 @@ try
|
|||||||
|
|
||||||
Console.WriteLine("== reset-world makes everyone reconnect");
|
Console.WriteLine("== reset-world makes everyone reconnect");
|
||||||
var r1 = await TestClient.Join("Resetter", 0, null);
|
var r1 = await TestClient.Join("Resetter", 0, null);
|
||||||
server.EnqueueCommand("reset-world");
|
Srv().EnqueueCommand("reset-world");
|
||||||
var rec = await r1.WaitFor(MsgType.Reconnect);
|
var rec = await r1.WaitFor(MsgType.Reconnect);
|
||||||
Check(rec != null && new BinaryReader(new MemoryStream(rec)).ReadString().Contains("reset"), "Players get the 'world is being reset' reconnect message");
|
Check(rec != null && new BinaryReader(new MemoryStream(rec)).ReadString().Contains("reset"), "Players get the 'world is being reset' reconnect message");
|
||||||
await Task.Delay(300);
|
await Task.Delay(300);
|
||||||
@@ -269,19 +272,19 @@ try
|
|||||||
string backupsDir = Path.Combine(dir, "save_backups");
|
string backupsDir = Path.Combine(dir, "save_backups");
|
||||||
int before = Directory.GetDirectories(backupsDir).Length;
|
int before = Directory.GetDirectories(backupsDir).Length;
|
||||||
await Task.Delay(1100); // Backups heißen nach der Sekunde
|
await Task.Delay(1100); // Backups heißen nach der Sekunde
|
||||||
server.EnqueueCommand("backup");
|
Srv().EnqueueCommand("backup");
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
Check(Directory.GetDirectories(backupsDir).Length == Math.Min(before + 1, 5), "'backup' creates a new backup of the current save");
|
Check(Directory.GetDirectories(backupsDir).Length == Math.Min(before + 1, 5), "'backup' creates a new backup of the current save");
|
||||||
// Aktuellen Spielstand verändern, dann das neueste Backup (= CURRENT-SAVE) zurückholen
|
// Aktuellen Spielstand verändern, dann das neueste Backup (= CURRENT-SAVE) zurückholen
|
||||||
File.WriteAllText(Path.Combine(dir, "save", "defaultES2File.txt"), "BROKEN");
|
File.WriteAllText(Path.Combine(dir, "save", "defaultES2File.txt"), "BROKEN");
|
||||||
server.EnqueueCommand("restore-save 1");
|
Srv().EnqueueCommand("restore-save 1");
|
||||||
var restoreMsg = await newcomer.WaitFor(MsgType.Reconnect);
|
var restoreMsg = await newcomer.WaitFor(MsgType.Reconnect);
|
||||||
Check(restoreMsg != null, "Restoring a backup makes everyone reconnect");
|
Check(restoreMsg != null, "Restoring a backup makes everyone reconnect");
|
||||||
Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).EndsWith("CURRENT-SAVE"), "The backup's files are back in the active save");
|
Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).EndsWith("CURRENT-SAVE"), "The backup's files are back in the active save");
|
||||||
|
|
||||||
Console.WriteLine("== reset-world goes back to the ORIGINAL save (not the auto-uploaded one)");
|
Console.WriteLine("== reset-world goes back to the ORIGINAL save (not the auto-uploaded one)");
|
||||||
var r2 = await TestClient.Join("Resetter2", 0, null);
|
var r2 = await TestClient.Join("Resetter2", 0, null);
|
||||||
server.EnqueueCommand("reset-world");
|
Srv().EnqueueCommand("reset-world");
|
||||||
Check(await r2.WaitFor(MsgType.Reconnect) != null, "Players reconnect after the reset");
|
Check(await r2.WaitFor(MsgType.Reconnect) != null, "Players reconnect after the reset");
|
||||||
Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "test defaultES2File.txt",
|
Check(File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "test defaultES2File.txt",
|
||||||
"The active save is the original one again: " + File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).Length + " chars");
|
"The active save is the original one again: " + File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")).Length + " chars");
|
||||||
@@ -303,7 +306,7 @@ catch (Exception e)
|
|||||||
async Task PanelTests()
|
async Task PanelTests()
|
||||||
{
|
{
|
||||||
Console.WriteLine("== Web panel on the game port");
|
Console.WriteLine("== Web panel on the game port");
|
||||||
Check(server.Panel != null && server.Panel.SharesGamePort, "Panel started and shares the game port");
|
Check(host.Panel != null && host.Panel.SharesGamePort, "Panel started and shares the game port");
|
||||||
string baseUrl = $"http://127.0.0.1:{config.Port}/";
|
string baseUrl = $"http://127.0.0.1:{config.Port}/";
|
||||||
HttpClient Browser()
|
HttpClient Browser()
|
||||||
{
|
{
|
||||||
@@ -334,10 +337,10 @@ async Task PanelTests()
|
|||||||
Check((await Call(owner, "GET", "api/players")).status == 401, "API needs a login");
|
Check((await Call(owner, "GET", "api/players")).status == 401, "API needs a login");
|
||||||
Check((await Call(owner, "POST", "api/setup", new { code = "WRONG-CODE0", username = "owner", password = "supersecret1" })).status == 403,
|
Check((await Call(owner, "POST", "api/setup", new { code = "WRONG-CODE0", username = "owner", password = "supersecret1" })).status == 403,
|
||||||
"Wrong setup code is rejected");
|
"Wrong setup code is rejected");
|
||||||
Check((await Call(owner, "POST", "api/setup", new { code = server.Panel.SetupCode, username = "owner", password = "supersecret1" }, csrf: false)).status == 403,
|
Check((await Call(owner, "POST", "api/setup", new { code = host.Panel.SetupCode, username = "owner", password = "supersecret1" }, csrf: false)).status == 403,
|
||||||
"Requests without the panel header are rejected (CSRF)");
|
"Requests without the panel header are rejected (CSRF)");
|
||||||
var setup = await Call(owner, "POST", "api/setup", new { code = server.Panel.SetupCode, username = "owner", password = "supersecret1" });
|
var setup = await Call(owner, "POST", "api/setup", new { code = host.Panel.SetupCode, username = "owner", password = "supersecret1" });
|
||||||
Check(setup.status == 200 && server.Panel.SetupCode == null, "Setup with the console code creates the owner and logs in");
|
Check(setup.status == 200 && host.Panel.SetupCode == null, "Setup with the console code creates the owner and logs in");
|
||||||
Check(File.Exists(Path.Combine(dir, "panel_users.json")) && !File.ReadAllText(Path.Combine(dir, "panel_users.json")).Contains("supersecret1"),
|
Check(File.Exists(Path.Combine(dir, "panel_users.json")) && !File.ReadAllText(Path.Combine(dir, "panel_users.json")).Contains("supersecret1"),
|
||||||
"Users are stored with a password hash, not the password");
|
"Users are stored with a password hash, not the password");
|
||||||
|
|
||||||
@@ -399,17 +402,50 @@ async Task PanelTests()
|
|||||||
&& File.ReadAllText(configPath).Contains("Renamed Server"), "Settings are changed and saved to server.json");
|
&& File.ReadAllText(configPath).Contains("Renamed Server"), "Settings are changed and saved to server.json");
|
||||||
Check((await Call(mod, "POST", "api/config", new { name = "Hacked" })).status == 403, "Moderators cannot change settings");
|
Check((await Call(mod, "POST", "api/config", new { name = "Hacked" })).status == 403, "Moderators cannot change settings");
|
||||||
|
|
||||||
// Spiel stoppen / starten
|
// Server stoppen - das Panel bleibt da
|
||||||
var stop = await Call(owner, "POST", "api/server/stop-game");
|
var online = await TestClient.Join("OnlineWhenStopped", 0, null);
|
||||||
Check(stop.status == 200, "Game server can be stopped from the panel");
|
var stop = await Call(owner, "POST", "api/server/stop");
|
||||||
|
Check(stop.status == 200, "Server can be stopped from the panel");
|
||||||
|
var stopMsg = await online.WaitFor(MsgType.Reject, 3000);
|
||||||
|
Check(stopMsg != null && ReadString(stopMsg).Contains("stopped by owner"), "Players get told who stopped the server: " + (stopMsg == null ? "-" : ReadString(stopMsg)));
|
||||||
|
await WaitForState(HostState.Stopped);
|
||||||
|
Check(host.State == HostState.Stopped && host.Server == null, "The game server is stopped");
|
||||||
|
Check((await Call(owner, "GET", "api/session")).status == 200, "The panel is still reachable while the server is stopped");
|
||||||
|
var offStatus = await Call(owner, "GET", "api/status");
|
||||||
|
Check(offStatus.status == 200 && offStatus.json.GetProperty("state").GetString() == "stopped", "Status shows the stopped server");
|
||||||
|
Check((await Call(owner, "GET", "api/players")).status == 200, "Player list works while stopped (empty)");
|
||||||
var whileStopped = await TestClient.Join("WhileStopped", 0, null, expectReject: true);
|
var whileStopped = await TestClient.Join("WhileStopped", 0, null, expectReject: true);
|
||||||
Check(whileStopped.RejectReason != null && whileStopped.RejectReason.Contains("stopped"), "Nobody can join while stopped: " + whileStopped.RejectReason);
|
Check(whileStopped.RejectReason != null && whileStopped.RejectReason.Contains("offline"), "Players are told the server is offline: " + whileStopped.RejectReason);
|
||||||
Check((await Call(owner, "GET", "api/status")).json.GetProperty("gameStopped").GetBoolean(), "Status shows the stopped game server");
|
var offCfg = await Call(owner, "POST", "api/config", new { description = "edited while stopped" });
|
||||||
Check((await Call(owner, "POST", "api/server/start-game")).status == 200, "Game server can be started again");
|
Check(offCfg.status == 200 && File.ReadAllText(configPath).Contains("edited while stopped"), "Settings can be changed while stopped");
|
||||||
|
var offBackup = await Call(owner, "POST", "api/world/backup");
|
||||||
|
Check(offBackup.status == 200 && offBackup.json.GetProperty("message").GetString().Contains("Backup"), "Backups work while stopped");
|
||||||
|
Check((await Call(owner, "POST", "api/players/1/kick")).status == 409, "Player actions say the server is not running");
|
||||||
|
// Spielstand hochladen, während der Server aus ist - wird beim nächsten Start benutzt
|
||||||
|
byte[] offlineZip;
|
||||||
|
using (var oms = new MemoryStream())
|
||||||
|
{
|
||||||
|
using (var oa = new System.IO.Compression.ZipArchive(oms, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||||
|
using (var w = new StreamWriter(oa.CreateEntry("defaultES2File.txt").Open())) w.Write("OFFLINE SAVE");
|
||||||
|
offlineZip = oms.ToArray();
|
||||||
|
}
|
||||||
|
var offUp = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(offlineZip) };
|
||||||
|
offUp.Headers.Add("X-MSCO-Panel", "1");
|
||||||
|
offUp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
|
||||||
|
var offUpRes = await owner.SendAsync(offUp);
|
||||||
|
Check(offUpRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "OFFLINE SAVE"
|
||||||
|
&& File.ReadAllText(Path.Combine(dir, "save_original", "defaultES2File.txt")) == "OFFLINE SAVE",
|
||||||
|
"A save game ZIP can be uploaded while the server is stopped (also the new reset point)");
|
||||||
|
Check((await Call(mod, "POST", "api/server/start")).status == 403, "Moderators cannot start the server");
|
||||||
|
Check((await Call(owner, "POST", "api/server/start")).status == 200, "Server can be started again from the panel");
|
||||||
|
await WaitForState(HostState.Running);
|
||||||
|
Check(host.State == HostState.Running, "The server runs again");
|
||||||
var afterStart = await TestClient.Join("AfterStart", 0, null);
|
var afterStart = await TestClient.Join("AfterStart", 0, null);
|
||||||
Check(afterStart.Welcome != null, "Players can join after start");
|
Check(afterStart.Welcome != null, "Players can join after start");
|
||||||
|
Check(afterStart.Files.TryGetValue("defaultES2File.txt", out var startedSave) && System.Text.Encoding.UTF8.GetString(startedSave) == "OFFLINE SAVE",
|
||||||
|
"After start the server uses the save uploaded while it was stopped");
|
||||||
afterStart.Close();
|
afterStart.Close();
|
||||||
Check((await Call(mod, "POST", "api/server/stop-game")).status == 403, "Moderators cannot stop the server");
|
Check((await Call(mod, "POST", "api/server/stop")).status == 403, "Moderators cannot stop the server");
|
||||||
|
|
||||||
// Spielstand als ZIP
|
// Spielstand als ZIP
|
||||||
byte[] zip;
|
byte[] zip;
|
||||||
@@ -440,8 +476,8 @@ async Task PanelTests()
|
|||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"\n{passed} passed, {failed} failed");
|
Console.WriteLine($"\n{passed} passed, {failed} failed");
|
||||||
server.EnqueueCommand("stop");
|
host.RequestExit();
|
||||||
Thread.Sleep(300);
|
hostThread.Join(5000);
|
||||||
try { Directory.Delete(dir, true); } catch { }
|
try { Directory.Delete(dir, true); } catch { }
|
||||||
return failed == 0 ? 0 : 1;
|
return failed == 0 ? 0 : 1;
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -17,7 +17,9 @@ services:
|
|||||||
# Als dein Linux-Benutzer laufen (nicht root), damit du in data/ selbst Dateien anlegen/ändern darfst.
|
# Als dein Linux-Benutzer laufen (nicht root), damit du in data/ selbst Dateien anlegen/ändern darfst.
|
||||||
# Andere ID? `id -u` / `id -g` ausgeben und in .env als MSCO_UID / MSCO_GID eintragen.
|
# Andere ID? `id -u` / `id -g` ausgeben und in .env als MSCO_UID / MSCO_GID eintragen.
|
||||||
user: "${MSCO_UID:-1000}:${MSCO_GID:-1000}"
|
user: "${MSCO_UID:-1000}:${MSCO_GID:-1000}"
|
||||||
restart: "on-failure:3" # bei fehlendem Schlüssel/Spielstand nicht endlos neu starten
|
# Das Programm läuft immer weiter (Web-Panel), auch wenn der Gameserver aus ist oder nicht starten kann
|
||||||
|
# (fehlender Schlüssel/Spielstand -> im Panel einrichten). Nach einem Neustart des Rechners startet es wieder.
|
||||||
|
restart: unless-stopped
|
||||||
stdin_open: true # Server-Konsole per docker attach
|
stdin_open: true # Server-Konsole per docker attach
|
||||||
tty: true
|
tty: true
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
|||||||
Reference in New Issue
Block a user