Server 2.10.0: distribute MSCO mods from data/mods
- ModStore: mods in data/mods (id, name, version, SHA-256), console "mods [reload]" - ModList is sent before Welcome, files on request in 60 KB parts (once per connection) - ModMessage relayed between players (max 64 KB) - Heartbeat reports the mods to the keymaster (automatic "modded" tag) - Web panel page "Mods": upload, remove, reload (DE/EN) - Tests for list, download, checksum, relay and limits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -497,6 +497,48 @@ public sealed class AdminPanel
|
|||||||
return await RunText(s => s.RunCommandCaptured(command, by));
|
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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
||||||
|
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
|
// --- Welt / Backups
|
||||||
app.MapGet("/api/backups", async (HttpContext ctx) =>
|
app.MapGet("/api/backups", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
|
|||||||
+62
-3
@@ -50,6 +50,48 @@ public sealed class GameServer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime TrackPositionFrom = DateTime.MaxValue;
|
public DateTime TrackPositionFrom = DateTime.MaxValue;
|
||||||
public Task<string> Verification;
|
public Task<string> Verification;
|
||||||
|
/// <summary>Schon geschickte Mod-Dateien (jede höchstens einmal pro Verbindung).</summary>
|
||||||
|
public readonly HashSet<string> ModsSent = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Mods (data/mods)
|
||||||
|
List<ServerMod> mods = new();
|
||||||
|
const int ModPartBytes = 60_000, MaxModMessageBytes = 64 * 1024;
|
||||||
|
|
||||||
|
/// <summary>Mods aus data/mods (neu) laden. Neue Spieler bekommen die neue Liste; Verbundene erst beim nächsten Beitritt.</summary>
|
||||||
|
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;
|
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\").");
|
"Get a free key at https://steamcommunity.com/dev/apikey and put it into server.json (\"SteamWebApiKey\").");
|
||||||
Log("Type 'help' for commands.");
|
Log("Type 'help' for commands.");
|
||||||
|
|
||||||
|
if (Directory.Exists(ModStore.Folder(DataDir)) || mods.Count > 0) ReloadMods();
|
||||||
|
|
||||||
nextHeartbeat = 0;
|
nextHeartbeat = 0;
|
||||||
nextAutosave = config.AutosaveSeconds;
|
nextAutosave = config.AutosaveSeconds;
|
||||||
|
|
||||||
@@ -309,7 +353,8 @@ public sealed class GameServer
|
|||||||
if (!offline && 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,
|
||||||
|
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
|
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.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.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;
|
||||||
|
|
||||||
/// <summary>Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients).</summary>
|
/// <summary>Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients).</summary>
|
||||||
int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min();
|
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());
|
ReceiveSavePart(c, m.Reader());
|
||||||
return;
|
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:
|
case MsgType.SaveUploadDone:
|
||||||
FinishSaveUpload(c, m.Reader());
|
FinishSaveUpload(c, m.Reader());
|
||||||
return;
|
return;
|
||||||
@@ -491,6 +541,9 @@ public sealed class GameServer
|
|||||||
if (++c.DestroyCount > MaxDestroyPerMinute) return;
|
if (++c.DestroyCount > MaxDestroyPerMinute) return;
|
||||||
world.AddDestroyed(BitConverter.ToInt32(m.Payload, 0));
|
world.AddDestroyed(BitConverter.ToInt32(m.Payload, 0));
|
||||||
break;
|
break;
|
||||||
|
case MsgType.ModMessage:
|
||||||
|
if (m.Payload.Length > MaxModMessageBytes) return;
|
||||||
|
break;
|
||||||
case MsgType.Chat:
|
case MsgType.Chat:
|
||||||
if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten
|
if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten
|
||||||
Log($"[chat] {c.Name}: {m.Reader().ReadString()}");
|
Log($"[chat] {c.Name}: {m.Reader().ReadString()}");
|
||||||
@@ -552,6 +605,7 @@ public sealed class GameServer
|
|||||||
c.State = ClientState.Joined;
|
c.State = ClientState.Joined;
|
||||||
c.JoinedAt = DateTime.UtcNow;
|
c.JoinedAt = DateTime.UtcNow;
|
||||||
var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList();
|
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 =>
|
c.Conn.Send(MsgType.Welcome, Wire.Build(w =>
|
||||||
{
|
{
|
||||||
w.Write(c.Id);
|
w.Write(c.Id);
|
||||||
@@ -937,7 +991,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 | restart | stop | exit | say <text> | op <id> | deop <id> | admins | save | reset-world | new-game | 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 | new-game | reload-save | mods [reload] | stop");
|
||||||
break;
|
break;
|
||||||
case "say":
|
case "say":
|
||||||
if (args.Length < 2) { Log("say <text>"); break; }
|
if (args.Length < 2) { Log("say <text>"); break; }
|
||||||
@@ -989,6 +1043,11 @@ public sealed class GameServer
|
|||||||
case "status":
|
case "status":
|
||||||
Log($"{config.Name} - {JoinedCount}/{config.MaxPlayers} players, {world.Objects.Count} objects, {world.Events.Count} actions");
|
Log($"{config.Name} - {JoinedCount}/{config.MaxPlayers} players, {world.Objects.Count} objects, {world.Events.Count} actions");
|
||||||
break;
|
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":
|
case "players":
|
||||||
foreach (var c in clients.Values.Where(c => c.State == ClientState.Joined))
|
foreach (var c in clients.Values.Where(c => c.State == ClientState.Joined))
|
||||||
Log($" [{c.Id}] {c.Name} SteamID {c.SteamId} {c.Conn.RemoteAddress}");
|
Log($" [{c.Id}] {c.Name} SteamID {c.SteamId} {c.Conn.RemoteAddress}");
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>MSCO.Server</RootNamespace>
|
<RootNamespace>MSCO.Server</RootNamespace>
|
||||||
<AssemblyName>MSCO.Server</AssemblyName>
|
<AssemblyName>MSCO.Server</AssemblyName>
|
||||||
<Version>2.9.6</Version>
|
<Version>2.10.0</Version>
|
||||||
<Company>MBM</Company>
|
<Company>MBM</Company>
|
||||||
<Product>MSCO Dedicated Server</Product>
|
<Product>MSCO Dedicated Server</Product>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
|||||||
+6
-3
@@ -22,7 +22,10 @@ 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, List<string> Tags);
|
string Version, bool Public, bool RequireSteam, List<string> Tags, List<ModInfo> Mods);
|
||||||
|
|
||||||
|
/// <summary>Mod des Servers für die Serverliste (die Dateien selbst bekommen Spieler direkt vom Server).</summary>
|
||||||
|
public sealed record ModInfo(string Id, string Name, string Version);
|
||||||
|
|
||||||
readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
readonly string baseUrl;
|
readonly string baseUrl;
|
||||||
@@ -67,13 +70,13 @@ public sealed class MasterClient
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HeartbeatResult> HeartbeatAsync(ServerConfig config, int players, string version)
|
public async Task<HeartbeatResult> HeartbeatAsync(ServerConfig config, int players, string version, List<ModInfo> mods = null)
|
||||||
{
|
{
|
||||||
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 _));
|
ServerConfig.NormalizeTags(config.Tags, out _), mods ?? new List<ModInfo>());
|
||||||
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);
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>Eine Mod, die der Server an seine Spieler verteilt (data/mods/<id>.dll).</summary>
|
||||||
|
public sealed record ServerMod(string Id, string Name, string Version, string Sha256, byte[] Data)
|
||||||
|
{
|
||||||
|
public int Size => Data.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// <summary>Alle Mods aus data/mods lesen (ungültige werden mit Meldung übersprungen).</summary>
|
||||||
|
public static List<ServerMod> Load(string dataDir, Action<string> log)
|
||||||
|
{
|
||||||
|
var list = new List<ServerMod>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mod hochladen (Panel). Gibt einen Fehler oder null zurück.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,6 +171,7 @@ const PAGES = [
|
|||||||
{ id: "admins", label: "Admins", icon: "★", role: "Moderator", group: "Moderation", render: renderAdmins },
|
{ id: "admins", label: "Admins", icon: "★", role: "Moderator", group: "Moderation", render: renderAdmins },
|
||||||
{ id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans },
|
{ id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans },
|
||||||
{ id: "world", label: "World & Backups", icon: "⟲", role: "Admin", group: "Management", render: renderWorld },
|
{ 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: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings },
|
||||||
{ id: "update", label: "Update", icon: "⇪", role: "Admin", group: "Management", render: renderUpdate },
|
{ id: "update", label: "Update", icon: "⇪", role: "Admin", group: "Management", render: renderUpdate },
|
||||||
{ id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers },
|
{ id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers },
|
||||||
@@ -754,6 +755,63 @@ function renderWorld(page) {
|
|||||||
every(15000, loadBackups);
|
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
|
// ------------------------------------------------------------------ Benutzer
|
||||||
|
|
||||||
function renderUsers(page) {
|
function renderUsers(page) {
|
||||||
|
|||||||
@@ -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":
|
"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",
|
"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
|
// Neues Spiel
|
||||||
"New game": "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.":
|
"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.":
|
||||||
|
|||||||
+6
-1
@@ -48,7 +48,12 @@ public enum MsgType : byte
|
|||||||
WorldVars = 41, // Jobs/Laden: int n, je [int Schlüssel, byte Typ, Wert]
|
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]
|
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]
|
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)
|
public readonly record struct Message(MsgType Type, byte[] Payload)
|
||||||
|
|||||||
@@ -207,6 +207,15 @@ erreichbar. Ganz aus: `"PanelEnabled": false`.
|
|||||||
> (`- "40120:40120/tcp"`). Schon ausgesperrt? `"PanelPort": 0` in `data/server.json` setzen und
|
> (`- "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.
|
> `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
|
## Konsolenbefehle
|
||||||
|
|
||||||
| Befehl | Beschreibung |
|
| 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` |
|
| `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 |
|
| `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 |
|
| `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,
|
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
|
**Admin geben/nehmen**, GOTO/BRING (zu einem Spieler / Spieler zu sich teleportieren), Cheats (Geld, Bedürfnisse
|
||||||
|
|||||||
@@ -233,6 +233,61 @@ try
|
|||||||
spammer.Close();
|
spammer.Close();
|
||||||
await Task.Delay(300);
|
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");
|
Console.WriteLine("== reset-world makes everyone reconnect");
|
||||||
var r1 = await TestClient.Join("Resetter", 0, null);
|
var r1 = await TestClient.Join("Resetter", 0, null);
|
||||||
r1.Send(MsgType.Ready, Array.Empty<byte>());
|
r1.Send(MsgType.Ready, Array.Empty<byte>());
|
||||||
|
|||||||
Reference in New Issue
Block a user