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:
2026-10-03 01:00:23 +02:00
co-authored by Claude Opus 5.5
parent 5e07fe1001
commit 3a79c928d1
10 changed files with 355 additions and 8 deletions
+62 -3
View File
@@ -50,6 +50,48 @@ public sealed class GameServer
/// </summary>
public DateTime TrackPositionFrom = DateTime.MaxValue;
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;
@@ -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;
/// <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();
@@ -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 <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;
case "say":
if (args.Length < 2) { Log("say <text>"); 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}");