diff --git a/AdminPanel.cs b/AdminPanel.cs index 42c575d..1e6fe8f 100644 --- a/AdminPanel.cs +++ b/AdminPanel.cs @@ -497,6 +497,48 @@ public sealed class AdminPanel return await RunText(s => s.RunCommandCaptured(command, by)); }); + // --- Mods (data/mods): bekommen die Spieler beim Beitreten vom Server + app.MapGet("/api/mods", async (HttpContext ctx) => + { + var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; + return await Run(s => s.PanelMods(), () => ModStore.Load(dataDir, null) + .Select(m => new { id = m.Id, name = m.Name, version = m.Version, sha256 = m.Sha256, size = m.Size }).ToList()); + }); + + app.MapPost("/api/mods/upload", async (HttpContext ctx) => + { + var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; + var sizeFeature = ctx.Features.Get(); + if (sizeFeature != null && !sizeFeature.IsReadOnly) sizeFeature.MaxRequestBodySize = ModStore.MaxModBytes + 1024; + byte[] data; + try + { + using var ms = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(ms); + data = ms.ToArray(); + } + catch (Exception e) { return Error(400, "Upload failed: " + e.Message); } + string name = ctx.Request.Query["name"].ToString(); + string error = ModStore.Add(dataDir, name, data); + if (error != null) return Error(400, error); + GameServer.Log($"[panel] {By(user)} uploaded the mod {Path.GetFileName(name)}"); + return await RunText(s => s.ReloadMods(), () => "Mod uploaded - players get it when the server runs"); + }); + + app.MapPost("/api/mods/{id}/remove", async (HttpContext ctx, string id) => + { + var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; + if (!ModStore.Remove(dataDir, id)) return Error(404, "Mod not found"); + GameServer.Log($"[panel] {By(user)} removed the mod {id}"); + return await RunText(s => s.ReloadMods(), () => "Mod removed"); + }); + + app.MapPost("/api/mods/reload", async (HttpContext ctx) => + { + var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; + return await RunText(s => s.ReloadMods(), () => "The server is stopped - mods are loaded when it starts"); + }); + // --- Welt / Backups app.MapGet("/api/backups", async (HttpContext ctx) => { diff --git a/GameServer.cs b/GameServer.cs index 47c22f7..9895517 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -50,6 +50,48 @@ public sealed class GameServer /// public DateTime TrackPositionFrom = DateTime.MaxValue; public Task Verification; + /// Schon geschickte Mod-Dateien (jede höchstens einmal pro Verbindung). + public readonly HashSet ModsSent = new(); + } + + // ---------------------------------------------------------------- Mods (data/mods) + List mods = new(); + const int ModPartBytes = 60_000, MaxModMessageBytes = 64 * 1024; + + /// Mods aus data/mods (neu) laden. Neue Spieler bekommen die neue Liste; Verbundene erst beim nächsten Beitritt. + public string ReloadMods() + { + mods = ModStore.Load(DataDir, Log); + string text = mods.Count == 0 ? "No mods (put MSCO mods into data/mods)" + : $"{mods.Count} mod(s): " + string.Join(", ", mods.Select(m => m.Name + (m.Version.Length > 0 ? " " + m.Version : ""))); + Log(text); + HeartbeatSoon(); + return text; + } + + public object PanelMods() => mods.Select(m => new { id = m.Id, name = m.Name, version = m.Version, sha256 = m.Sha256, size = m.Size }).ToList(); + + void SendModList(Client c) + { + if (mods.Count == 0) return; + c.Conn.Send(MsgType.ModList, Wire.Build(w => + { + w.Write(mods.Count); + foreach (var m in mods) { w.Write(m.Id); w.Write(m.Name); w.Write(m.Version); w.Write(m.Sha256); w.Write(m.Size); } + })); + } + + void SendModFile(Client c, string id) + { + var mod = mods.FirstOrDefault(m => m.Id == id); + if (mod == null || !c.ModsSent.Add(id)) return; + for (int offset = 0; offset < mod.Data.Length; offset += ModPartBytes) + { + int len = Math.Min(ModPartBytes, mod.Data.Length - offset), off = offset; + c.Conn.Send(MsgType.ModFilePart, Wire.Build(w => { w.Write(mod.Id); w.Write(mod.Data.Length); w.Write(off); w.Write(len); w.Write(mod.Data, off, len); })); + } + c.Conn.Send(MsgType.ModFileDone, Wire.Build(w => w.Write(mod.Id))); + Log($"Sent mod {mod.Name} to {c.Name} ({mod.Size / 1024} KB)"); } readonly ServerConfig config; @@ -177,6 +219,8 @@ public sealed class GameServer "Get a free key at https://steamcommunity.com/dev/apikey and put it into server.json (\"SteamWebApiKey\")."); Log("Type 'help' for commands."); + if (Directory.Exists(ModStore.Folder(DataDir)) || mods.Count > 0) ReloadMods(); + nextHeartbeat = 0; nextAutosave = config.AutosaveSeconds; @@ -309,7 +353,8 @@ public sealed class GameServer if (!offline && now >= nextHeartbeat && heartbeat == null) { nextHeartbeat = now + HeartbeatSeconds; - heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion); + heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion, + mods.Select(m => new MasterClient.ModInfo(m.Id, m.Name, m.Version)).ToList()); } } @@ -333,7 +378,7 @@ public sealed class GameServer t is MsgType.Player or MsgType.ObjStates or MsgType.ObjOwn or MsgType.ObjRelease or MsgType.FsmEvent or MsgType.VehicleFx or MsgType.WorldTime or MsgType.MoneyDelta or MsgType.DoorState or MsgType.Chat or MsgType.VarSync or MsgType.SpawnItem or MsgType.ItemAppeared or MsgType.ObjDestroy or MsgType.VehicleDamage - or MsgType.TrafficStates or MsgType.WorldVars or MsgType.HingeStates or MsgType.VehicleControls; + or MsgType.TrafficStates or MsgType.WorldVars or MsgType.HingeStates or MsgType.VehicleControls or MsgType.ModMessage; /// Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients). int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min(); @@ -384,6 +429,11 @@ public sealed class GameServer ReceiveSavePart(c, m.Reader()); return; + case MsgType.ModFileRequest: + // Spieler hat beim Beitreten zugestimmt, die Mods dieses Servers zu laden + if (c.State == ClientState.Joined && m.Payload.Length <= 200) SendModFile(c, m.Reader().ReadString()); + return; + case MsgType.SaveUploadDone: FinishSaveUpload(c, m.Reader()); return; @@ -491,6 +541,9 @@ public sealed class GameServer if (++c.DestroyCount > MaxDestroyPerMinute) return; world.AddDestroyed(BitConverter.ToInt32(m.Payload, 0)); break; + case MsgType.ModMessage: + if (m.Payload.Length > MaxModMessageBytes) return; + break; case MsgType.Chat: if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten Log($"[chat] {c.Name}: {m.Reader().ReadString()}"); @@ -552,6 +605,7 @@ public sealed class GameServer c.State = ClientState.Joined; c.JoinedAt = DateTime.UtcNow; var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList(); + SendModList(c); // vor Welcome: die Mod weiß dann beim Beitreten, welche Mods der Server hat (ältere Mods ignorieren es) c.Conn.Send(MsgType.Welcome, Wire.Build(w => { w.Write(c.Id); @@ -937,7 +991,7 @@ public sealed class GameServer switch (args[0].ToLowerInvariant()) { case "help": - Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | panel | restart | stop | exit | say | op | deop | admins | save | reset-world | new-game | reload-save | stop"); + Log("status | players | kick [reason] | ban [reason] | bans | unban | backups | backup | restore-save | set-reset-point | panel | restart | stop | exit | say | op | deop | admins | save | reset-world | new-game | reload-save | mods [reload] | stop"); break; case "say": if (args.Length < 2) { Log("say "); break; } @@ -989,6 +1043,11 @@ public sealed class GameServer case "status": Log($"{config.Name} - {JoinedCount}/{config.MaxPlayers} players, {world.Objects.Count} objects, {world.Events.Count} actions"); break; + case "mods": + if (args.Length > 1 && args[1].Trim().Equals("reload", StringComparison.OrdinalIgnoreCase)) ReloadMods(); + else if (mods.Count == 0) Log("No mods - put MSCO mods (.dll) into " + ModStore.Folder(DataDir) + " and type 'mods reload'"); + else foreach (var md in mods) Log($" {md.Id}: {md.Name} {md.Version} ({md.Size / 1024} KB, SHA-256 {md.Sha256[..12]}…)"); + break; case "players": foreach (var c in clients.Values.Where(c => c.State == ClientState.Joined)) Log($" [{c.Id}] {c.Name} SteamID {c.SteamId} {c.Conn.RemoteAddress}"); diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj index a2e18d4..1da3262 100644 --- a/MSCO.Server.csproj +++ b/MSCO.Server.csproj @@ -7,7 +7,7 @@ enable MSCO.Server MSCO.Server - 2.9.6 + 2.10.0 MBM MSCO Dedicated Server true diff --git a/MasterClient.cs b/MasterClient.cs index d41d575..3b1a90b 100644 --- a/MasterClient.cs +++ b/MasterClient.cs @@ -22,7 +22,10 @@ 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, List Tags); + string Version, bool Public, bool RequireSteam, List Tags, List Mods); + + /// Mod des Servers für die Serverliste (die Dateien selbst bekommen Spieler direkt vom Server). + public sealed record ModInfo(string Id, string Name, string Version); readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) }; readonly string baseUrl; @@ -67,13 +70,13 @@ public sealed class MasterClient catch { } } - public async Task HeartbeatAsync(ServerConfig config, int players, string version) + public async Task HeartbeatAsync(ServerConfig config, int players, string version, List mods = null) { try { var body = new HeartbeatBody(config.LicenseKey, config.Name, config.Description, config.PublicAddress, config.Port, players, config.MaxPlayers, version, config.Public, config.RequireSteam, - ServerConfig.NormalizeTags(config.Tags, out _)); + ServerConfig.NormalizeTags(config.Tags, out _), mods ?? new List()); 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/ModStore.cs b/ModStore.cs new file mode 100644 index 0000000..fda6dc6 --- /dev/null +++ b/ModStore.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace MSCO.Server; + +/// Eine Mod, die der Server an seine Spieler verteilt (data/mods/<id>.dll). +public sealed record ServerMod(string Id, string Name, string Version, string Sha256, byte[] Data) +{ + public int Size => Data.Length; +} + +/// +/// Server-Mods: MSCO-Mods (DLLs) in data/mods. Beim Beitreten bekommt jeder Spieler die Liste; fehlende Mods lädt die +/// MSCO-Mod nach Zustimmung des Spielers vom Server herunter (MsgType.ModFileRequest). Verwalten: Dateien in den Ordner +/// legen bzw. im Web-Panel unter "Mods" hochladen, danach "mods reload" (Panel: automatisch). +/// +public static class ModStore +{ + public const int MaxModBytes = 20 * 1024 * 1024; + public const int MaxMods = 30; + static readonly Regex IdPattern = new("^[A-Za-z0-9_.-]{1,64}$"); + + public static string Folder(string dataDir) => Path.Combine(dataDir, "mods"); + + /// Alle Mods aus data/mods lesen (ungültige werden mit Meldung übersprungen). + public static List Load(string dataDir, Action log) + { + var list = new List(); + string dir = Folder(dataDir); + if (!Directory.Exists(dir)) return list; + foreach (string file in Directory.GetFiles(dir, "*.dll").OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) + { + if (list.Count >= MaxMods) { log?.Invoke($"More than {MaxMods} mods in data/mods - the rest is ignored"); break; } + try + { + byte[] data = File.ReadAllBytes(file); + string id = Path.GetFileNameWithoutExtension(file); + string error = Check(id, data); + if (error != null) { log?.Invoke($"Mod {Path.GetFileName(file)} skipped: {error}"); continue; } + list.Add(Describe(file, id, data)); + } + catch (Exception e) { log?.Invoke($"Mod {Path.GetFileName(file)} could not be read: {e.Message}"); } + } + return list; + } + + static ServerMod Describe(string file, string id, byte[] data) + { + string name = id, version = ""; + try + { + var info = FileVersionInfo.GetVersionInfo(file); + if (!string.IsNullOrWhiteSpace(info.ProductName)) name = info.ProductName.Trim(); + version = (info.ProductVersion ?? info.FileVersion ?? "").Split('+')[0].Trim(); + } + catch { } + string sha = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + return new ServerMod(id, Clip(name, 64), Clip(version, 32), sha, data); + } + + static string Clip(string s, int max) => s.Length > max ? s[..max] : s; + + static string Check(string id, byte[] data) + { + if (!IdPattern.IsMatch(id)) return "the file name may only contain letters, digits, '.', '-' and '_'"; + if (data.Length < 64 || data.Length > MaxModBytes) return $"size must be up to {MaxModBytes / 1024 / 1024} MB"; + if (data[0] != 0x4d || data[1] != 0x5a) return "not a .NET DLL"; + return null; + } + + /// Mod hochladen (Panel). Gibt einen Fehler oder null zurück. + public static string Add(string dataDir, string fileName, byte[] data) + { + fileName = Path.GetFileName(fileName ?? ""); + if (!fileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return "Choose a .dll file"; + string id = Path.GetFileNameWithoutExtension(fileName); + string error = Check(id, data); + if (error != null) return "This mod cannot be used: " + error; + string dir = Folder(dataDir); + Directory.CreateDirectory(dir); + if (!File.Exists(Path.Combine(dir, id + ".dll")) && Directory.GetFiles(dir, "*.dll").Length >= MaxMods) + return $"At most {MaxMods} mods"; + string target = Path.Combine(dir, id + ".dll"), tmp = target + ".upload"; + File.WriteAllBytes(tmp, data); + File.Move(tmp, target, true); + return null; + } + + public static bool Remove(string dataDir, string id) + { + if (!IdPattern.IsMatch(id ?? "")) return false; + string file = Path.Combine(Folder(dataDir), id + ".dll"); + if (!File.Exists(file)) return false; + File.Delete(file); + return true; + } +} diff --git a/Panel/app.js b/Panel/app.js index 390a5ea..ee0676e 100644 --- a/Panel/app.js +++ b/Panel/app.js @@ -171,6 +171,7 @@ const PAGES = [ { id: "admins", label: "Admins", icon: "★", role: "Moderator", group: "Moderation", render: renderAdmins }, { id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans }, { id: "world", label: "World & Backups", icon: "⟲", role: "Admin", group: "Management", render: renderWorld }, + { id: "mods", label: "Mods", icon: "⧉", role: "Admin", group: "Management", render: renderMods }, { id: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings }, { id: "update", label: "Update", icon: "⇪", role: "Admin", group: "Management", render: renderUpdate }, { id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers }, @@ -754,6 +755,63 @@ function renderWorld(page) { every(15000, loadBackups); } +// ------------------------------------------------------------------ Mods + +function renderMods(page) { + const rows = h("tbody"); + const fileInput = h("input", { type: "file", accept: ".dll" }); + const kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB"; + const upload = async () => { + const file = fileInput.files && fileInput.files[0]; + if (!file) { toast("Choose a mod (.dll) first", true); return; } + try { + const res = await fetch("api/mods/upload?name=" + encodeURIComponent(file.name), { method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/octet-stream", "X-MSCO-Panel": "1" }, body: file }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status); + toast(data.message); + fileInput.value = ""; + load(); + } catch (e) { toast(e.message, true); } + }; + const remove = async (m) => { + const ok = await modal({ title: "Remove " + m.name + "?", danger: true, confirm: "Remove", + text: "New players no longer get this mod. Players who already have it keep it on their PC." }); + if (!ok) return; + await act(api("POST", "mods/" + encodeURIComponent(m.id) + "/remove")); + load(); + }; + + page.append( + h("div", { class: "cards wide" }, + h("div", { class: "card" }, + h("div", { class: "stat-label" }, "Add a mod"), + h("p", { class: "muted" }, "MSCO mods (.dll) from this list are offered to every player when they join. The player sees name, version, size and checksum and decides whether to install them. Only add mods you trust - they run on the players' PCs."), + h("div", { class: "row" }, fileInput, + h("button", { class: "btn primary", type: "button", onclick: upload }, "Upload"), + h("button", { class: "btn ghost", type: "button", onclick: async () => { await act(api("POST", "mods/reload")); load(); } }, "Reload"))), + h("div", { class: "card" }, + h("div", { class: "stat-label" }, "Folder"), + h("p", { class: "muted" }, "The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload)."))), + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Server mods"), h("span", { class: "muted" }, "players get these when they join")), + h("div", { class: "table-wrap" }, h("table", {}, + h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Size"), h("th", {}, "SHA-256"), h("th", {}, ""))), rows)))); + + async function load() { + try { + const list = await api("GET", "mods"); + if (!list.length) { rows.replaceChildren(h("tr", {}, h("td", { colspan: 5, class: "empty" }, "No mods - players join without mods."))); return; } + rows.replaceChildren(...list.map((m) => h("tr", {}, + h("td", {}, h("b", {}, m.name), h("div", { class: "muted mono" }, m.id + ".dll")), + h("td", {}, m.version || "–"), h("td", {}, kb(m.size)), + h("td", { class: "mono muted", title: m.sha256 }, m.sha256.slice(0, 16) + "…"), + h("td", {}, h("div", { class: "actions" }, h("button", { class: "btn small danger", type: "button", onclick: () => remove(m) }, "Remove")))))); + } catch (e) { toast(e.message, true); } + } + load(); +} + // ------------------------------------------------------------------ Benutzer function renderUsers(page) { diff --git a/Panel/i18n.js b/Panel/i18n.js index 863831a..c61b741 100644 --- a/Panel/i18n.js +++ b/Panel/i18n.js @@ -16,6 +16,23 @@ "The web panel is turned off - turn it on again with 'panel on' in the server console or PanelEnabled in server.json": "Das Web-Panel ist ausgeschaltet - wieder an mit 'panel on' in der Server-Konsole oder PanelEnabled in server.json", + // Mods + "Mods": "Mods", + "Add a mod": "Mod hinzufügen", + "MSCO mods (.dll) from this list are offered to every player when they join. The player sees name, version, size and checksum and decides whether to install them. Only add mods you trust - they run on the players' PCs.": + "MSCO-Mods (.dll) aus dieser Liste werden jedem Spieler beim Beitreten angeboten. Er sieht Name, Version, Größe und Prüfsumme und entscheidet, ob er sie installiert. Nur Mods hinzufügen, denen du vertraust - sie laufen auf den PCs der Spieler.", + "Reload": "Neu laden", + "The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload).": + "Die Mods liegen in data/mods. Von Hand dorthin kopierte Dateien gelten nach „Neu laden“ (Konsole: mods reload).", + "Server mods": "Server-Mods", + "players get these when they join": "bekommen Spieler beim Beitreten", + "Mod": "Mod", + "Size": "Größe", + "No mods - players join without mods.": "Keine Mods - Spieler treten ohne Mods bei.", + "Choose a mod (.dll) first": "Erst eine Mod (.dll) auswählen", + "New players no longer get this mod. Players who already have it keep it on their PC.": + "Neue Spieler bekommen diese Mod nicht mehr. Wer sie schon hat, behält sie auf seinem PC.", + // Neues Spiel "New game": "Neues Spiel", "Start a completely fresh My Summer Car game instead of an uploaded save. The current save game is backed up first. The first player to join creates the new world.": diff --git a/Protocol.cs b/Protocol.cs index 1ef74fc..0bae6e4 100644 --- a/Protocol.cs +++ b/Protocol.cs @@ -48,7 +48,12 @@ public enum MsgType : byte WorldVars = 41, // Jobs/Laden: int n, je [int Schlüssel, byte Typ, Wert] HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, float Scharnier-Winkel] VehicleControls = 43, // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand] - PlayerNeeds = 44 // Client -> Server: eigene Bedürfnisse (Hunger, Durst ...): int n, je [string Name, float Wert] + PlayerNeeds = 44, // Client -> Server: eigene Bedürfnisse (Hunger, Durst ...): int n, je [string Name, float Wert] + ModMessage = 45, // Nachricht einer MSCO-Mod an die anderen Spieler: string Mod-ID, int Länge, bytes (weitergeleitet mit Absender) + ModList = 46, // Server -> Client (vor Welcome): int n, je [string ID, string Name, string Version, string SHA-256, int Größe] + ModFileRequest = 47, // Client -> Server: string Mod-ID + ModFilePart = 48, // Server -> Client: string Mod-ID, int Gesamtgröße, int Offset, int Länge, bytes + ModFileDone = 49 // Server -> Client: string Mod-ID } public readonly record struct Message(MsgType Type, byte[] Payload) diff --git a/README.md b/README.md index d625875..b7706d9 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,15 @@ erreichbar. Ganz aus: `"PanelEnabled": false`. > (`- "40120:40120/tcp"`). Schon ausgesperrt? `"PanelPort": 0` in `data/server.json` setzen und > `docker compose restart` – dann ist das Panel wieder auf dem Spiel-Port. +## Mods + +MSCO-Mods (.dll, siehe [MODDING.md](https://git.mbm.wtf/MrMaho/MSCO/src/branch/main/MODDING.md)) in `data/mods` – oder +im Web-Panel unter **Mods** hochladen. Jeder Spieler bekommt beim Beitreten die Liste; fehlende Mods lädt sein Spiel +nach seiner Zustimmung direkt vom Server (SHA-256 geprüft, höchstens 30 Mods à 20 MB). In der Serverliste bekommt der +Server automatisch den Tag **MODDED** und zeigt seine Mods an. Nach Änderungen im Ordner: `mods reload` (Panel: automatisch). + +> Mods laufen auf den PCs der Spieler – nur Mods anbieten, denen du vertraust. + ## Konsolenbefehle | Befehl | Beschreibung | @@ -227,6 +236,7 @@ erreichbar. Ganz aus: `"PanelEnabled": false`. | `exit` | ganzes Programm beenden (auch das Panel) – mit Docker startet es danach wieder, dauerhaft aus: `docker compose stop` | | `new-game` | Spielstand sichern und ein frisches neues Spiel starten (der erste Spieler legt die Welt an) – alle verbinden neu | | `panel [on\|off]` | Adresse des Web-Panels und ggf. den Einrichtungs-Code anzeigen / Panel an- oder ausschalten | +| `mods`, `mods reload` | Server-Mods anzeigen / `data/mods` neu einlesen | Im Spiel öffnen Admins mit `/admin` (Chat, Taste T) oder `admin` (F7-Konsole) das Admin-Menü: Kick/Ban, **Admin geben/nehmen**, GOTO/BRING (zu einem Spieler / Spieler zu sich teleportieren), Cheats (Geld, Bedürfnisse diff --git a/Tests/Program.cs b/Tests/Program.cs index 938c555..37d33f4 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -233,6 +233,61 @@ try spammer.Close(); await Task.Delay(300); + Console.WriteLine("== Mods (data/mods)"); + var noModsClient = await TestClient.Join("NoMods", 0, null); + Check(await noModsClient.WaitFor(MsgType.ModList, 300) == null, "Without mods the server sends no mod list"); + noModsClient.Close(); + Directory.CreateDirectory(Path.Combine(dir, "mods")); + var modBytes = new byte[150_000]; + new Random(7).NextBytes(modBytes); + modBytes[0] = 0x4d; modBytes[1] = 0x5a; // "MZ" + File.WriteAllBytes(Path.Combine(dir, "mods", "testmod.dll"), modBytes); + File.WriteAllText(Path.Combine(dir, "mods", "bad name!.dll"), "MZ nope"); + Srv().EnqueueCommand("mods reload"); + await Task.Delay(500); + var modder = await TestClient.Join("Modder", 0, null); + var modList = await modder.WaitFor(MsgType.ModList, 1000); + Check(modList != null, "Players get the server's mod list when they join"); + if (modList != null) + { + var mr = new BinaryReader(new MemoryStream(modList)); + int modCount = mr.ReadInt32(); + string modId = mr.ReadString(), modName = mr.ReadString(), modVersion = mr.ReadString(), modSha = mr.ReadString(); + int modSize = mr.ReadInt32(); + Check(modCount == 1 && modId == "testmod" && modSize == modBytes.Length, $"Mod list has the valid mod only ({modCount}: {modId}, {modSize} bytes)"); + modder.Send(MsgType.ModFileRequest, Wire.Build(w => w.Write("testmod"))); + var modData = new byte[modSize]; + int received = 0; + byte[] part; + while ((part = await modder.WaitFor(MsgType.ModFilePart, 1500)) != null) + { + var pr = new BinaryReader(new MemoryStream(part)); + pr.ReadString(); int total = pr.ReadInt32(), off = pr.ReadInt32(), len = pr.ReadInt32(); + pr.ReadBytes(len).CopyTo(modData, off); + received += len; + if (received >= total) break; + } + Check(await modder.WaitFor(MsgType.ModFileDone, 1500) != null, "Mod download ends with ModFileDone"); + string gotSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(modData)).ToLowerInvariant(); + Check(received == modSize && gotSha == modSha, "Downloaded mod matches the checksum from the list"); + modder.Send(MsgType.ModFileRequest, Wire.Build(w => w.Write("testmod"))); + Check(await modder.WaitFor(MsgType.ModFilePart, 600) == null, "Each mod is sent only once per connection"); + modder.Send(MsgType.ModFileRequest, Wire.Build(w => w.Write("../server"))); + Check(await modder.WaitFor(MsgType.ModFilePart, 400) == null, "Unknown mod ids are ignored"); + } + var modPeer = await TestClient.Join("ModPeer", 0, null); + modder.Send(MsgType.ModMessage, Wire.Build(w => { w.Write("hellomod"); w.Write(4); w.Write(new byte[] { 1, 2, 3, 4 }); })); + var modMsg = await modPeer.WaitFor(MsgType.ModMessage); + Check(modMsg != null && BitConverter.ToInt32(modMsg, 0) == modder.Id, "Mod messages are relayed to the other players with the sender"); + modder.Send(MsgType.ModMessage, new byte[70_000]); + Check(await modPeer.WaitFor(MsgType.ModMessage, 600) == null, "Oversized mod messages are dropped"); + modder.Close(); + modPeer.Close(); + File.Delete(Path.Combine(dir, "mods", "testmod.dll")); + File.Delete(Path.Combine(dir, "mods", "bad name!.dll")); + Srv().EnqueueCommand("mods reload"); + await Task.Delay(300); + Console.WriteLine("== reset-world makes everyone reconnect"); var r1 = await TestClient.Join("Resetter", 0, null); r1.Send(MsgType.Ready, Array.Empty());