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:
+62
-3
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user