Own server tags in the web panel (Settings) and server.json

Tags (max. 6, normalized like the keymaster) are sent with the heartbeat;
official and featured are only given by the MSCO admins and are refused with
a note. MSCO_TAGS environment variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:20:06 +02:00
co-authored by Claude Opus 5.5
parent 1ab9a01efb
commit e523e5e17b
6 changed files with 63 additions and 4 deletions
+11 -2
View File
@@ -10,6 +10,7 @@ public static class ConfigEditor
{ {
name = config.Name, name = config.Name,
description = config.Description, description = config.Description,
tags = string.Join(", ", config.Tags ?? new()),
maxPlayers = config.MaxPlayers, maxPlayers = config.MaxPlayers,
isPublic = config.Public, isPublic = config.Public,
requireSteam = config.RequireSteam, requireSteam = config.RequireSteam,
@@ -63,6 +64,13 @@ public static class ConfigEditor
if (name.Length == 0) return "The server name cannot be empty"; if (name.Length == 0) return "The server name cannot be empty";
config.Name = name; config.Name = name;
config.Description = Text("description", config.Description, 300); config.Description = Text("description", config.Description, 300);
string tagNote = "";
if (b.TryGetProperty("tags", out var tagValue) && tagValue.ValueKind == System.Text.Json.JsonValueKind.String)
{
var tags = ServerConfig.NormalizeTags(tagValue.GetString().Split(','), out var reserved);
if (!tags.SequenceEqual(config.Tags ?? new())) { config.Tags = tags; changed.Add("tags"); }
if (reserved.Count > 0) tagNote = $" - the tag {string.Join(", ", reserved)} is only given by the MSCO admins";
}
config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64); config.MaxPlayers = Number("maxPlayers", config.MaxPlayers, 1, 64);
config.Public = Flag("isPublic", config.Public); config.Public = Flag("isPublic", config.Public);
config.RequireSteam = Flag("requireSteam", config.RequireSteam); config.RequireSteam = Flag("requireSteam", config.RequireSteam);
@@ -93,7 +101,7 @@ public static class ConfigEditor
restartNeeded = true; restartNeeded = true;
} }
if (changed.Count == 0) return "Nothing changed"; if (changed.Count == 0) return "Nothing changed" + tagNote;
try { config.Save(configPath); } try { config.Save(configPath); }
catch (Exception e) { return "Could not save server.json: " + e.Message; } catch (Exception e) { return "Could not save server.json: " + e.Message; }
GameServer.Log($"Settings changed by {by}: {string.Join(", ", changed)}"); GameServer.Log($"Settings changed by {by}: {string.Join(", ", changed)}");
@@ -101,6 +109,7 @@ public static class ConfigEditor
return "Settings saved" + return "Settings saved" +
(portChanged ? " - the new port only applies after the whole program restarts (docker compose restart); in Docker publish it in docker-compose.yml first" (portChanged ? " - the new port only applies after the whole program restarts (docker compose restart); in Docker publish it in docker-compose.yml first"
: restartNeeded ? " - some changes need a restart" : "") + : restartNeeded ? " - some changes need a restart" : "") +
(overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : ""); (overridden.Count > 0 ? " (note: " + string.Join(", ", overridden) + " is also set by an environment variable, which wins on restart)" : "") +
tagNote;
} }
} }
+3 -2
View File
@@ -22,7 +22,7 @@ public sealed class MasterClient
public bool Unreachable { get; init; } public bool Unreachable { get; init; }
} }
record HeartbeatBody(string Key, string Name, string Description, string Address, int Port, int Players, int MaxPlayers, record HeartbeatBody(string Key, string Name, string Description, string Address, int Port, int Players, int MaxPlayers,
string Version, bool Public, bool RequireSteam); string Version, bool Public, bool RequireSteam, List<string> Tags);
readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) }; readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) };
readonly string baseUrl; readonly string baseUrl;
@@ -72,7 +72,8 @@ public sealed class MasterClient
try try
{ {
var body = new HeartbeatBody(config.LicenseKey, config.Name, config.Description, config.PublicAddress, config.Port, var body = new HeartbeatBody(config.LicenseKey, config.Name, config.Description, config.PublicAddress, config.Port,
players, config.MaxPlayers, version, config.Public, config.RequireSteam); players, config.MaxPlayers, version, config.Public, config.RequireSteam,
ServerConfig.NormalizeTags(config.Tags, out _));
var response = await http.PostAsJsonAsync(baseUrl + "/api/servers/heartbeat", body); var response = await http.PostAsJsonAsync(baseUrl + "/api/servers/heartbeat", body);
int status = (int)response.StatusCode; int status = (int)response.StatusCode;
if (!response.IsSuccessStatusCode) return new HeartbeatResult(status, null, false, null, 0); if (!response.IsSuccessStatusCode) return new HeartbeatResult(status, null, false, null, 0);
+2
View File
@@ -396,6 +396,8 @@ function renderSettings(page) {
h("div", { class: "section-body form-grid wide" }, h("div", { class: "section-body form-grid wide" },
text("name", "Server name", c.name, { max: 60 }), text("name", "Server name", c.name, { max: 60 }),
text("description", "Description", c.description, { max: 300 }), text("description", "Description", c.description, { max: 300 }),
text("tags", "Tags", c.tags, { max: 140, placeholder: "e.g. german, roleplay, vanilla",
hint: "Shown in the server list, comma separated (max. 6). The tag official is only given by the MSCO admins." }),
number("maxPlayers", "Max players", c.maxPlayers, { min: 1, max: 64 }), number("maxPlayers", "Max players", c.maxPlayers, { min: 1, max: 64 }),
text("publicAddress", "Public address", c.publicAddress, { max: 200, placeholder: "automatic", hint: "IP or domain players connect to (empty = automatic)" }), text("publicAddress", "Public address", c.publicAddress, { max: 200, placeholder: "automatic", hint: "IP or domain players connect to (empty = automatic)" }),
toggle("isPublic", "Listed in the server browser", c.isPublic), toggle("isPublic", "Listed in the server browser", c.isPublic),
+8
View File
@@ -13,6 +13,12 @@
window.MSCO_LANG = lang; window.MSCO_LANG = lang;
const DE = { const DE = {
// Tags
"Tags": "Tags",
"e.g. german, roleplay, vanilla": "z.B. deutsch, roleplay, vanilla",
"Shown in the server list, comma separated (max. 6). The tag official is only given by the MSCO admins.":
"Werden in der Serverliste angezeigt, mit Komma getrennt (max. 6). Den Tag official vergeben nur die MSCO-Admins.",
// Updates // Updates
"Update": "Update", "Update": "Update",
"Update available": "Update verfügbar", "Update available": "Update verfügbar",
@@ -377,6 +383,7 @@
[/^Save game uploaded \((\d+) files\) - start the server to use it$/, "Spielstand hochgeladen ($1 Dateien) - starte den Server, um ihn zu benutzen"], [/^Save game uploaded \((\d+) files\) - start the server to use it$/, "Spielstand hochgeladen ($1 Dateien) - starte den Server, um ihn zu benutzen"],
[/^Settings saved(.*)$/, (m, rest) => "Einstellungen gespeichert" + rest [/^Settings saved(.*)$/, (m, rest) => "Einstellungen gespeichert" + rest
.replace(" - some changes need a restart", " - manche Änderungen brauchen einen Neustart") .replace(" - some changes need a restart", " - manche Änderungen brauchen einen Neustart")
.replace(/ - the tag (.+) is only given by the MSCO admins$/, " - den Tag $1 vergeben nur die MSCO-Admins")
.replace(" - the new port only applies after the whole program restarts (docker compose restart); in Docker publish it in docker-compose.yml first", .replace(" - the new port only applies after the whole program restarts (docker compose restart); in Docker publish it in docker-compose.yml first",
" - der neue Port gilt erst nach einem Neustart des ganzen Programms (docker compose restart); in Docker vorher in docker-compose.yml freigeben")], " - der neue Port gilt erst nach einem Neustart des ganzen Programms (docker compose restart); in Docker vorher in docker-compose.yml freigeben")],
[/^The server is already (\w+)$/, (m, s) => "Der Server " + (s === "running" ? "läuft schon" : "startet schon")], [/^The server is already (\w+)$/, (m, s) => "Der Server " + (s === "running" ? "läuft schon" : "startet schon")],
@@ -385,6 +392,7 @@
[/^No license key set - (.+)$/, "Kein Lizenzschlüssel gesetzt - hol dir einen unter msco.mbm.wtf/keys und trag ihn in den Einstellungen ein"], [/^No license key set - (.+)$/, "Kein Lizenzschlüssel gesetzt - hol dir einen unter msco.mbm.wtf/keys und trag ihn in den Einstellungen ein"],
[/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"], [/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"],
[/^Update available: (.+)$/, "Update verfügbar: $1"], [/^Update available: (.+)$/, "Update verfügbar: $1"],
[/^(.*) - the tag (.+) is only given by the MSCO admins$/, (m, before, tag) => (DE[before] || before) + " - den Tag " + tag + " vergeben nur die MSCO-Admins"],
[/^Version (.+) is available$/, "Version $1 ist verfügbar"], [/^Version (.+) is available$/, "Version $1 ist verfügbar"],
[/^Version ([0-9.]+)$/, "Version $1"], [/^Version ([0-9.]+)$/, "Version $1"],
[/^Install update (.+)\?$/, "Update $1 installieren?"], [/^Install update (.+)\?$/, "Update $1 installieren?"],
+28
View File
@@ -8,6 +8,11 @@ public sealed class ServerConfig
public string LicenseKey { get; set; } = ""; public string LicenseKey { get; set; } = "";
public string Name { get; set; } = "My MSCO Server"; public string Name { get; set; } = "My MSCO Server";
public string Description { get; set; } = ""; public string Description { get; set; } = "";
/// <summary>
/// Eigene Tags für die Serverliste (z.B. "deutsch", "roleplay"), höchstens 6. "official" und "featured" vergeben
/// nur die MSCO-Admins im Keymaster.
/// </summary>
public List<string> Tags { get; set; } = new();
public int Port { get; set; } = 7777; public int Port { get; set; } = 7777;
/// <summary> /// <summary>
/// Adresse, unter der Spieler den Server erreichen (IP oder Domain). Leer = die IP, von der /// Adresse, unter der Spieler den Server erreichen (IP oder Domain). Leer = die IP, von der
@@ -90,6 +95,7 @@ public sealed class ServerConfig
Name = Take("MSCO_NAME", Name); Name = Take("MSCO_NAME", Name);
Description = Take("MSCO_DESCRIPTION", Description); Description = Take("MSCO_DESCRIPTION", Description);
PublicAddress = Take("MSCO_PUBLIC_ADDRESS", PublicAddress); PublicAddress = Take("MSCO_PUBLIC_ADDRESS", PublicAddress);
if (Env("MSCO_TAGS") is string tags) { Tags = NormalizeTags(tags.Split(','), out _); used.Add("MSCO_TAGS"); }
SteamWebApiKey = Take("MSCO_STEAM_WEB_API_KEY", SteamWebApiKey); SteamWebApiKey = Take("MSCO_STEAM_WEB_API_KEY", SteamWebApiKey);
if (int.TryParse(Env("MSCO_PORT"), out int port)) { Port = port; used.Add("MSCO_PORT"); } if (int.TryParse(Env("MSCO_PORT"), out int port)) { Port = port; used.Add("MSCO_PORT"); }
if (int.TryParse(Env("MSCO_MAX_PLAYERS"), out int max)) { MaxPlayers = max; used.Add("MSCO_MAX_PLAYERS"); } if (int.TryParse(Env("MSCO_MAX_PLAYERS"), out int max)) { MaxPlayers = max; used.Add("MSCO_MAX_PLAYERS"); }
@@ -103,6 +109,28 @@ public sealed class ServerConfig
return used; return used;
} }
public const int MaxTags = 6;
/// <summary>Diese Tags vergeben nur die MSCO-Admins (Keymaster), nicht der Server selbst.</summary>
public static readonly string[] ReservedTags = { "official", "featured" };
/// <summary>Wie der Keymaster: "Deutsch, Role Play!" -> ["deutsch", "role-play"]; reservierte fallen weg.</summary>
public static List<string> NormalizeTags(IEnumerable<string> input, out List<string> reserved)
{
reserved = new List<string>();
var tags = new List<string>();
foreach (string raw in input ?? Array.Empty<string>())
{
string t = (raw ?? "").Trim().ToLowerInvariant().Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
t = System.Text.RegularExpressions.Regex.Replace(t, @"\s+", "-");
t = System.Text.RegularExpressions.Regex.Replace(t, "[^a-z0-9-]", "").Trim('-');
if (t.Length > 20) t = t[..20].Trim('-');
if (t.Length == 0 || tags.Contains(t)) continue;
if (ReservedTags.Contains(t)) { if (!reserved.Contains(t)) reserved.Add(t); continue; }
if (tags.Count < MaxTags) tags.Add(t);
}
return tags;
}
static string Env(string name) static string Env(string name)
{ {
string value = Environment.GetEnvironmentVariable(name); string value = Environment.GetEnvironmentVariable(name);
+11
View File
@@ -486,6 +486,17 @@ async Task PanelTests()
using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip))) using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip)))
Check(down.IsSuccessStatusCode && da.GetEntry("defaultES2File.txt") != null, "The current save can be downloaded as ZIP"); Check(down.IsSuccessStatusCode && da.GetEntry("defaultES2File.txt") != null, "The current save can be downloaded as ZIP");
// Eigene Tags: bereinigt, "official"/"featured" nur über die MSCO-Admins
var cleanTags = ServerConfig.NormalizeTags(new[] { " Deutsch ", "Role Play!", "OFFICIAL", "für alle", "deutsch", "featured" }, out var reservedTags);
Check(string.Join(",", cleanTags) == "deutsch,role-play,fuer-alle" && string.Join(",", reservedTags) == "official,featured",
"Tags are normalized and official/featured are reserved: " + string.Join(",", cleanTags));
var tagRes = await Call(owner, "POST", "api/config", new { tags = "Vanilla, official, Deutsch" });
Check(tagRes.status == 200 && tagRes.json.GetProperty("message").GetString().Contains("only given by the MSCO admins")
&& string.Join(",", ServerConfig.LoadOrCreate(configPath, out _).Tags) == "vanilla,deutsch",
"Own tags can be set in the panel, official is refused: " + tagRes.json);
var tagCfg = await Call(owner, "GET", "api/config");
Check(tagCfg.json.GetProperty("tags").GetString() == "vanilla, deutsch", "The panel shows the tags");
// Updates: Versionen vergleichen, Status im Panel, Installieren nur mit verfügbarem Update // Updates: Versionen vergleichen, Status im Panel, Installieren nur mit verfügbarem Update
Check(Updater.ParseCsprojVersion("<Project><PropertyGroup><Version>2.10.1</Version></PropertyGroup></Project>") == "2.10.1" Check(Updater.ParseCsprojVersion("<Project><PropertyGroup><Version>2.10.1</Version></PropertyGroup></Project>") == "2.10.1"
&& Updater.ParseModVersion("public const string ModVersion = \"2.9\";") == "2.9", "Update versions are read from the source"); && Updater.ParseModVersion("public const string ModVersion = \"2.9\";") == "2.9", "Update versions are read from the source");