diff --git a/ConfigEditor.cs b/ConfigEditor.cs index 4b039b8..91b3c0a 100644 --- a/ConfigEditor.cs +++ b/ConfigEditor.cs @@ -10,6 +10,7 @@ public static class ConfigEditor { name = config.Name, description = config.Description, + tags = string.Join(", ", config.Tags ?? new()), maxPlayers = config.MaxPlayers, isPublic = config.Public, requireSteam = config.RequireSteam, @@ -63,6 +64,13 @@ public static class ConfigEditor if (name.Length == 0) return "The server name cannot be empty"; config.Name = name; 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.Public = Flag("isPublic", config.Public); config.RequireSteam = Flag("requireSteam", config.RequireSteam); @@ -93,7 +101,7 @@ public static class ConfigEditor restartNeeded = true; } - if (changed.Count == 0) return "Nothing changed"; + if (changed.Count == 0) return "Nothing changed" + tagNote; try { config.Save(configPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; } GameServer.Log($"Settings changed by {by}: {string.Join(", ", changed)}"); @@ -101,6 +109,7 @@ public static class ConfigEditor 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" : 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; } } diff --git a/MasterClient.cs b/MasterClient.cs index 0412cf9..d41d575 100644 --- a/MasterClient.cs +++ b/MasterClient.cs @@ -22,7 +22,7 @@ public sealed class MasterClient public bool Unreachable { get; init; } } record HeartbeatBody(string Key, string Name, string Description, string Address, int Port, int Players, int MaxPlayers, - string Version, bool Public, bool RequireSteam); + string Version, bool Public, bool RequireSteam, List Tags); readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) }; readonly string baseUrl; @@ -72,7 +72,8 @@ public sealed class MasterClient try { 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); int status = (int)response.StatusCode; if (!response.IsSuccessStatusCode) return new HeartbeatResult(status, null, false, null, 0); diff --git a/Panel/app.js b/Panel/app.js index b2e91d4..3b77105 100644 --- a/Panel/app.js +++ b/Panel/app.js @@ -396,6 +396,8 @@ function renderSettings(page) { h("div", { class: "section-body form-grid wide" }, text("name", "Server name", c.name, { max: 60 }), 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 }), 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), diff --git a/Panel/i18n.js b/Panel/i18n.js index 670db63..479330e 100644 --- a/Panel/i18n.js +++ b/Panel/i18n.js @@ -13,6 +13,12 @@ window.MSCO_LANG = lang; 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 "Update": "Update", "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"], [/^Settings saved(.*)$/, (m, rest) => "Einstellungen gespeichert" + rest .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", " - 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")], @@ -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"], [/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $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 ([0-9.]+)$/, "Version $1"], [/^Install update (.+)\?$/, "Update $1 installieren?"], diff --git a/ServerConfig.cs b/ServerConfig.cs index 0c374e0..0b9fb82 100644 --- a/ServerConfig.cs +++ b/ServerConfig.cs @@ -8,6 +8,11 @@ public sealed class ServerConfig public string LicenseKey { get; set; } = ""; public string Name { get; set; } = "My MSCO Server"; public string Description { get; set; } = ""; + /// + /// Eigene Tags für die Serverliste (z.B. "deutsch", "roleplay"), höchstens 6. "official" und "featured" vergeben + /// nur die MSCO-Admins im Keymaster. + /// + public List Tags { get; set; } = new(); public int Port { get; set; } = 7777; /// /// 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); Description = Take("MSCO_DESCRIPTION", Description); 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); 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"); } @@ -103,6 +109,28 @@ public sealed class ServerConfig return used; } + public const int MaxTags = 6; + /// Diese Tags vergeben nur die MSCO-Admins (Keymaster), nicht der Server selbst. + public static readonly string[] ReservedTags = { "official", "featured" }; + + /// Wie der Keymaster: "Deutsch, Role Play!" -> ["deutsch", "role-play"]; reservierte fallen weg. + public static List NormalizeTags(IEnumerable input, out List reserved) + { + reserved = new List(); + var tags = new List(); + foreach (string raw in input ?? Array.Empty()) + { + 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) { string value = Environment.GetEnvironmentVariable(name); diff --git a/Tests/Program.cs b/Tests/Program.cs index 66e57a5..afee727 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -486,6 +486,17 @@ async Task PanelTests() 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"); + // 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 Check(Updater.ParseCsprojVersion("2.10.1") == "2.10.1" && Updater.ParseModVersion("public const string ModVersion = \"2.9\";") == "2.9", "Update versions are read from the source");