Beta Release

This commit is contained in:
2026-09-26 19:37:28 +02:00
parent adca4d9167
commit e77ccf2f33
29 changed files with 6819 additions and 293 deletions
+264
View File
@@ -0,0 +1,264 @@
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 Newtonsoft.Json;
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
[JsonIgnore] public int UptimeSeconds = -1;
[JsonIgnore] public List<string> PlayerNames = new List<string>();
[JsonIgnore] public int Ping = -1; // -1 = noch nicht abgefragt
[JsonIgnore] public bool Offline;
[JsonIgnore] 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
{
public const string DefaultMasterUrl = "https://msco.mbm.wtf";
const string PrefMasterUrl = "MSCO_MasterUrl";
const int QueryTimeoutMs = 3000;
// Wie bei FiveM: 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>Adresse des Master-Servers (änderbar mit "mp master &lt;url&gt;", z.B. zum lokalen Testen).</summary>
public static string MasterUrl
{
get { return PlayerPrefs.GetString(PrefMasterUrl, DefaultMasterUrl).TrimEnd('/'); }
set { PlayerPrefs.SetString(PrefMasterUrl, string.IsNullOrEmpty(value) ? DefaultMasterUrl : value.TrimEnd('/')); }
}
/// <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 = JsonConvert.DeserializeObject<List<ServerListEntry>>(text) ?? new List<ServerListEntry>(); }
catch (Exception e)
{
Error = "Invalid server list: " + e.Message;
yield break;
}
lock (gate) servers = list;
foreach (ServerListEntry entry in list) QueryAsync(entry);
}
/// <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;
}
}
}