Files
MSCO/ServerBrowser.cs
T
MrMahoandClaude Opus 5.5 4c1366b5d9 MSCO 2.10.0: mod support, ESC no longer quits from the main menu
- MscoMod API (ModApi.cs) and ModLoader: own mods from MSCO\Mods, hooks for menu/game/update/GUI,
  session and player events, Send/OnMessage between players (MsgType.ModMessage)
- Server mods: ModList before Welcome, consent prompt (INSTALL & JOIN), download with SHA-256 check
  into MSCO\Mods\Servers, active only on that server; game start waits for the download
- Mods page in the MSCO menu, console command "mods"
- Server browser: MODS pill, VANILLA/MODDED filters, mod list in the server profile
- PlayMaker ApplicationQuit is ignored in the main menu (ESC used to close the game)
- MODDING.md and examples/HelloMod

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:00:16 +02:00

306 lines
12 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Sockets;
using System.Threading;
// (Unitys WWW wird nicht mehr benutzt - siehe WinHttp)
using UnityEngine;
namespace MSCO
{
/// <summary>Ein Eintrag der Serverliste (vom Master-Server, Spielerzahl/Ping direkt beim Server abgefragt).</summary>
public class ServerListEntry
{
public string Name;
public string Description;
public string Address;
public int Port;
public int Players;
public int MaxPlayers;
public string Version;
public bool RequireSteam = true; // false = Non-Steam-Server
public string Owner; // Besitzer des Lizenzschlüssels
public bool Featured; // vom Keymaster-Admin hervorgehoben (steht in der Liste oben)
public List<string> Tags = new List<string>(); // Admin-Tags (z.B. "official") und eigene Tags des Servers (Web-Panel)
public List<string> Mods = new List<string>(); // Mods des Servers ("Name Version") - bekommt man beim Beitreten
public bool Modded { get { return Mods.Count > 0 || Tags.Contains("modded"); } }
public int UptimeSeconds = -1;
public List<string> PlayerNames = new List<string>();
public int Ping = -1; // -1 = noch nicht abgefragt
public bool Offline;
public bool IsLocal; // Server auf diesem PC (localhost)
}
/// <summary>
/// Holt die Serverliste vom Master-Server (Keymaster) und fragt jeden Server im Hintergrund
/// nach aktuellen Infos und Ping (MsgType.Info).
/// </summary>
public class ServerBrowser
{
/// <summary>Offizieller Master (Serverliste) - fest eingebaut.</summary>
public const string MasterUrl = "https://msco.mbm.wtf";
const int QueryTimeoutMs = 3000;
// Ein Server auf diesem PC erscheint automatisch oben in der Liste
static readonly int[] LocalPorts = { 7777, 7778, 7779, 7780 };
public const float AutoRefreshSeconds = 15f;
readonly object gate = new object();
List<ServerListEntry> servers = new List<ServerListEntry>();
readonly List<ServerListEntry> localServers = new List<ServerListEntry>();
public bool Loading { get; private set; }
public string Error { get; private set; }
/// <summary>Zeitpunkt der letzten Aktualisierung (Time.realtimeSinceStartup), für das Auto-Refresh.</summary>
public float LastRefresh { get; private set; }
/// <summary>Lokale Server zuerst, dann die öffentliche Liste.</summary>
public List<ServerListEntry> Servers
{
get
{
lock (gate)
{
var all = new List<ServerListEntry>(localServers);
all.AddRange(servers);
return all;
}
}
}
public void Refresh(MonoBehaviour host)
{
if (Loading) return;
host.StartCoroutine(Fetch());
}
IEnumerator Fetch()
{
Loading = true;
Error = null;
LastRefresh = Time.realtimeSinceStartup;
ProbeLocalServers(); // unabhängig vom Master: auch ohne Internet sichtbar
// Über WinHTTP (TLS 1.2/1.3) im Hintergrund - Unitys WWW kann nur TLS 1.0
string text = null, error = null;
bool done = false;
string url = MasterUrl + "/api/servers";
var thread = new Thread(() =>
{
text = WinHttp.Get(url, 8000, out error);
done = true;
});
thread.IsBackground = true;
thread.Start();
while (!done) yield return null;
Loading = false;
if (text == null)
{
Error = "Server list not reachable (" + error + ")";
yield break;
}
List<ServerListEntry> list;
try { list = ParseList(text); }
catch (Exception e)
{
Error = "Invalid server list: " + e.Message;
yield break;
}
lock (gate) servers = list;
foreach (ServerListEntry entry in list) QueryAsync(entry);
}
/// <summary>Antwort von /api/servers lesen (eigener JSON-Leser, siehe MiniJson).</summary>
static List<ServerListEntry> ParseList(string text)
{
var list = new List<ServerListEntry>();
var items = MiniJson.Parse(text) as List<object>;
if (items == null) throw new FormatException("Expected a list");
foreach (object item in items)
{
var o = item as Dictionary<string, object>;
if (o == null) continue;
list.Add(new ServerListEntry
{
Name = MiniJson.Str(o, "name", ""),
Description = MiniJson.Str(o, "description", ""),
Address = MiniJson.Str(o, "address", ""),
Port = MiniJson.Int(o, "port", 7777),
Players = MiniJson.Int(o, "players"),
MaxPlayers = MiniJson.Int(o, "maxPlayers"),
Version = MiniJson.Str(o, "version", ""),
RequireSteam = MiniJson.Bool(o, "requireSteam", true),
Owner = MiniJson.Str(o, "owner"),
Featured = MiniJson.Bool(o, "featured"),
Tags = MiniJson.Strings(o, "tags"),
Mods = ModNames(o),
});
}
return list;
}
static List<string> ModNames(Dictionary<string, object> o)
{
var names = new List<string>();
object v;
var list = o.TryGetValue("mods", out v) ? v as List<object> : null;
if (list == null) return names;
foreach (object item in list)
{
var m = item as Dictionary<string, object>;
if (m == null) continue;
string name = MiniJson.Str(m, "name", MiniJson.Str(m, "id", "?")), version = MiniJson.Str(m, "version", "");
names.Add(string.IsNullOrEmpty(version) ? name : name + " " + version);
}
return names;
}
/// <summary>Server auf diesem PC suchen (127.0.0.1, übliche Ports); nur antwortende werden gezeigt.</summary>
void ProbeLocalServers()
{
lock (gate) localServers.Clear();
foreach (int port in LocalPorts)
{
var entry = new ServerListEntry { Name = "Local server", Address = "127.0.0.1", Port = port, IsLocal = true, Version = "" };
QueryAsync(entry, found => localServers.Add(found));
}
}
/// <summary>Info + Ping im Hintergrund abfragen (blockiert das Spiel nicht).</summary>
void QueryAsync(ServerListEntry entry, Action<ServerListEntry> onOnline = null)
{
var thread = new Thread(() =>
{
ServerInfo info;
bool ok = QueryInfo(entry.Address, entry.Port, QueryTimeoutMs, out info);
lock (gate)
{
if (!ok)
{
entry.Offline = true;
return;
}
entry.Name = info.Name;
entry.Description = info.Description;
entry.Players = info.Players;
entry.MaxPlayers = info.MaxPlayers;
entry.Version = info.Version;
entry.Ping = info.Ping;
if (info.HasProfile)
{
entry.RequireSteam = info.RequireSteam;
if (!string.IsNullOrEmpty(info.Owner)) entry.Owner = info.Owner;
entry.UptimeSeconds = info.UptimeSeconds;
entry.PlayerNames = info.PlayerNames;
}
entry.Offline = false;
if (onOnline != null) onOnline(entry);
}
});
thread.IsBackground = true;
thread.Start();
}
/// <summary>Antwort eines Servers auf die Info-Abfrage.</summary>
public struct ServerInfo
{
public string Name, Description, Version;
public int Players, MaxPlayers, Ping;
// Server-Profil (ab MSCO 2.4; ältere Server liefern das nicht)
public bool HasProfile;
public bool RequireSteam;
public string Owner;
public int UptimeSeconds;
public List<string> PlayerNames;
}
/// <summary>Server direkt nach Name, Spielerzahl und Version fragen (blockierend - im Hintergrund aufrufen).</summary>
public static bool QueryInfo(string host, int port, int timeoutMs, out ServerInfo info)
{
info = new ServerInfo();
try
{
var watch = Stopwatch.StartNew();
using (var client = new TcpClient())
{
IAsyncResult connect = client.BeginConnect(host, port, null, null);
if (!connect.AsyncWaitHandle.WaitOne(timeoutMs)) return false;
client.EndConnect(connect);
client.ReceiveTimeout = timeoutMs;
NetworkStream stream = client.GetStream();
byte[] request = FrameParser.Frame(MsgType.Info, null);
stream.Write(request, 0, request.Length);
byte[] header = ReadExactly(stream, 5);
int length = BitConverter.ToInt32(header, 0);
if (header[4] != (byte)MsgType.InfoReply || length < 1 || length > 64 * 1024) return false;
byte[] payload = ReadExactly(stream, length - 1);
info.Ping = (int)watch.ElapsedMilliseconds;
using (var r = new BinaryReader(new MemoryStream(payload)))
{
info.Name = r.ReadString();
info.Description = r.ReadString();
info.Players = r.ReadInt32();
info.MaxPlayers = r.ReadInt32();
info.Version = r.ReadString();
info.RequireSteam = true;
if (r.BaseStream.Position < r.BaseStream.Length)
{
info.HasProfile = true;
info.RequireSteam = r.ReadBoolean();
info.Owner = r.ReadString();
info.UptimeSeconds = r.ReadInt32();
int count = r.ReadInt32();
info.PlayerNames = new List<string>();
for (int i = 0; i < count && i < 256; i++) info.PlayerNames.Add(r.ReadString());
}
}
return true;
}
}
catch
{
return false;
}
}
static byte[] ReadExactly(Stream stream, int count)
{
var data = new byte[count];
int read = 0;
while (read < count)
{
int n = stream.Read(data, read, count - read);
if (n <= 0) throw new IOException("Connection closed");
read += n;
}
return data;
}
/// <summary>"1.2.3.4:7777" oder "host" (Standard-Port) zerlegen.</summary>
public static bool TryParseAddress(string text, out string host, out int port)
{
host = null;
port = MultiplayerManager.DefaultPort;
if (string.IsNullOrEmpty(text)) return false;
text = text.Trim();
int colon = text.LastIndexOf(':');
if (colon > 0 && text.IndexOf(':') == colon)
{
if (!int.TryParse(text.Substring(colon + 1), out port) || port < 1 || port > 65535) return false;
text = text.Substring(0, colon);
}
host = text;
return host.Length > 0;
}
}
}