From fb8d6d6844305260995c3a73ca56c16019957f55 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 18:10:55 +0100 Subject: [PATCH] 0.2.0: Server-Browser und Spiel-Ticket - Seite Server: Liste der Website, Ping/Spieler direkt abgefragt, BEITRETEN und Direkt verbinden - Holt beim Spielen ein Spiel-Ticket und schreibt es in BSO.json Co-Authored-By: Claude Opus 5.5 --- BSO.Launcher.csproj | 2 +- MainForm.cs | 46 ++++++++++++++++++++++++++++-- Services/Api.cs | 14 +++++++++ Services/Game.cs | 5 ++-- Services/ServerPing.cs | 38 ++++++++++++++++++++++++ ui/app.css | 10 +++++++ ui/app.js | 65 +++++++++++++++++++++++++++++++++++++++++- ui/index.html | 1 + 8 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 Services/ServerPing.cs diff --git a/BSO.Launcher.csproj b/BSO.Launcher.csproj index b6639a2..944f443 100644 --- a/BSO.Launcher.csproj +++ b/BSO.Launcher.csproj @@ -8,7 +8,7 @@ enable BSO.Launcher BSO-Launcher - 0.1.1 + 0.2.0 MBM.wtf BSO Launcher BSO Launcher - Bus Simulator Online diff --git a/MainForm.cs b/MainForm.cs index 27124e6..001883d 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -19,9 +19,9 @@ public sealed class MainForm : Form readonly Settings settings = Settings.Load(); /// Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start). - static readonly HashSet Exclusive = new() { "installMod", "launch", "installLauncherUpdate", "uninstall" }; + static readonly HashSet Exclusive = new() { "installMod", "launch", "join", "installLauncherUpdate", "uninstall" }; readonly HashSet running = new(); - static readonly HashSet QuietCommands = new() { "window", "status", "me", "init", "releases" }; + static readonly HashSet QuietCommands = new() { "window", "status", "me", "init", "releases", "servers" }; bool updateInstalled; static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -288,6 +288,18 @@ public sealed class MainForm : Form return await InstallMod(); case "launch": return await Launch(); + case "servers": + return await ServerList(); + case "join": + { + // Beitreten: Server merken, beim Start automatisch verbinden, Spiel starten + string address = Arg(args, "address").Trim(); + if (address.Length == 0 || address.Length > 260) throw new Exception("Ungueltige Serveradresse"); + settings.Server = address; + settings.AutoConnect = true; + settings.Save(); + return await Launch(); + } case "releases": { var r = await Api.Releases(); @@ -363,6 +375,21 @@ public sealed class MainForm : Form }; } + /// Serverliste der Website, jeder Server direkt angefragt (Ping, aktuelle Spielerzahl). Nicht erreichbar = ohne Ping. + static async Task ServerList() + { + var servers = await Api.Servers(); + var pings = await Task.WhenAll(servers.Select(s => ServerPing.Query(s.host, s.port))); + return servers.Select((s, i) => new + { + s.name, s.description, s.address, s.version, + players = pings[i]?.Players ?? s.players, + maxPlayers = pings[i]?.MaxPlayers ?? s.maxPlayers, + ping = pings[i]?.Ping, + compatible = s.protocol == 0 || s.protocol == 2 // Protokoll der Mod, die der Launcher installiert + }).OrderBy(s => s.ping == null).ThenByDescending(s => s.players).ToList(); + } + async Task InstallMod() { string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen"); @@ -388,7 +415,20 @@ public sealed class MainForm : Form else if (installed == null) throw new Exception("BSO ist nicht installiert und der BSO-Server ist nicht erreichbar"); } - Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect); + // Spiel-Ticket: ohne gueltiges Konto kein Multiplayer (Mod und Server pruefen es) + string token = settings.Token ?? throw new Exception("Bitte anmelden"); + var t = await Api.Ticket(token); + if (t.Status == 401) + { + settings.Token = null; + settings.Save(); + Emit(new { @event = "loggedOut" }); + throw new Exception("Anmeldung abgelaufen - bitte neu anmelden"); + } + if (!t.Ok) throw new Exception("Konto konnte nicht geprueft werden: " + t.Error); + string ticket = t.Json.GetProperty("ticket").GetString()!; + + Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket); Game.Launch(); LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})"); if (!settings.KeepOpen) diff --git a/Services/Api.cs b/Services/Api.cs index 748b934..021e063 100644 --- a/Services/Api.cs +++ b/Services/Api.cs @@ -71,6 +71,20 @@ public static class Api public static Task Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token); + /// Spiel-Ticket fuer die Mod (signiert, 24 h) -> { ticket, username, expiresUtc } + public static Task Ticket(string token) => Send(HttpMethod.Post, "/api/auth/ticket", new { }, token); + + public sealed record ServerEntry(string name, string description, string address, string host, int port, int players, int maxPlayers, + string version, int protocol, string onlineSinceUtc); + + /// Oeffentliche, erreichbare Server (Serverliste der Website). + public static async Task> Servers() + { + var r = await Send(HttpMethod.Get, "/api/servers", null, null); + if (!r.Ok) throw new Exception(r.Error ?? "Serverliste nicht verfuegbar"); + return r.Json.GetProperty("servers").Deserialize>() ?? new List(); + } + public static async Task Latest(string product) { var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null); diff --git a/Services/Game.cs b/Services/Game.cs index 7c190da..70f759b 100644 --- a/Services/Game.cs +++ b/Services/Game.cs @@ -85,8 +85,8 @@ public static class Game LauncherLog.Info("BSO installiert nach " + bin); } - /// Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden. - public static void WriteModConfig(string folder, string? name, string server, bool autoConnect) + /// Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden, Spiel-Ticket. + public static void WriteModConfig(string folder, string? name, string server, bool autoConnect, string? ticket) { string file = Path.Combine(Bin(folder), "BSO", "BSO.json"); JsonObject json; @@ -95,6 +95,7 @@ public static class Game if (!string.IsNullOrEmpty(name)) json["Name"] = name; json["Server"] = server; json["AutoConnect"] = autoConnect; + json["Ticket"] = ticket; Directory.CreateDirectory(Path.GetDirectoryName(file)!); File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); } diff --git a/Services/ServerPing.cs b/Services/ServerPing.cs new file mode 100644 index 0000000..6b10d34 --- /dev/null +++ b/Services/ServerPing.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; + +namespace BSO.Launcher; + +/// +/// Fragt einen BSO-Server direkt ab wie die Mod (wie ServerBrowser.cs bei MSCO): Info senden, InfoReply lesen. Liefert die +/// Laufzeit (Ping) und den aktuellen Stand (Spieler) - die Liste der Website ist bis zu einer Minute alt. +/// Paketaufbau: [int Laenge][byte Typ][Daten], Info = 9, InfoReply = 10 (string Name, string Version, int Spieler, int max). +/// +public static class ServerPing +{ + public sealed record Info(int Ping, string Name, string Version, int Players, int MaxPlayers); + + public static async Task Query(string host, int port, int timeoutMs = 2500) + { + try + { + using var tcp = new TcpClient { NoDelay = true }; + using var cts = new CancellationTokenSource(timeoutMs); + var clock = Stopwatch.StartNew(); + await tcp.ConnectAsync(host, port, cts.Token); + var stream = tcp.GetStream(); + await stream.WriteAsync(new byte[] { 1, 0, 0, 0, 9 }, cts.Token); + var head = new byte[5]; + await stream.ReadExactlyAsync(head, cts.Token); + int ping = (int)clock.ElapsedMilliseconds; + int length = BitConverter.ToInt32(head, 0); + if (head[4] != 10 || length < 1 || length > 4096) return null; + var payload = new byte[length - 1]; + await stream.ReadExactlyAsync(payload, cts.Token); + using var r = new BinaryReader(new MemoryStream(payload), Encoding.UTF8); + return new Info(ping, r.ReadString(), r.ReadString(), r.ReadInt32(), r.ReadInt32()); + } + catch { return null; } + } +} diff --git a/ui/app.css b/ui/app.css index 7506d94..a89eb52 100644 --- a/ui/app.css +++ b/ui/app.css @@ -146,6 +146,16 @@ nav button.on { background: var(--panel-2); color: var(--text); } .dot.bad { background: var(--danger); } .row { display: flex; gap: 8px; align-items: center; } +/* ---------------------------------------------------------------- Server-Browser */ +.server-list { display: grid; gap: 8px; } +.server { display: grid; grid-template-columns: 1fr 70px 70px 110px auto; align-items: center; gap: 14px; padding: 14px 18px; + background: var(--panel); border: 1px solid var(--border); border-radius: 12px; } +.server:hover { border-color: #36404f; } +.server-main { display: grid; min-width: 0; } +.server-main b, .server-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.server-col { text-align: right; font-variant-numeric: tabular-nums; } +.ok { color: var(--online); } .warn { color: var(--pending); } .bad { color: var(--danger); } + /* ---------------------------------------------------------------- Neuigkeiten */ .release { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 6px; } .release header { display: flex; align-items: baseline; gap: 10px; } diff --git a/ui/app.js b/ui/app.js index 8428a11..bbf9aa7 100644 --- a/ui/app.js +++ b/ui/app.js @@ -140,9 +140,12 @@ function showPage(name) { document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name)); const root = $("page"); root.replaceChildren(h("p", { class: "muted" }, "Laden …")); - ({ home: renderHome, news: renderNews, settings: renderSettings })[name](root); + ({ home: renderHome, servers: renderServers, news: renderNews, settings: renderSettings })[name](root); } +// Anmeldung abgelaufen (z.B. beim Holen des Spiel-Tickets) +on("loggedOut", () => showAuth()); + // Fortschritt (Downloads) let progressBox = null; on("progress", (m) => { @@ -240,6 +243,66 @@ function serverCard(s) { h("label", { class: "switch" }, h("span", {}, "Beim Start verbinden", h("small", {}, "sonst im Spiel mit F8")), auto)); } +// ---------------------------------------------------------------- Server-Browser + +let serverList = []; + +async function renderServers(root) { + const search = h("input", { placeholder: "Server suchen …", spellcheck: "false" }); + const refresh = h("button", { class: "btn small", type: "button" }, "Aktualisieren"); + const list = h("div", { class: "server-list" }, h("p", { class: "muted" }, "Server werden gesucht …")); + const direct = h("input", { placeholder: "Adresse, z.B. bso.example.de:7777", spellcheck: "false" }); + const directJoin = h("button", { class: "btn small", type: "button" }, "Verbinden"); + directJoin.addEventListener("click", () => join(directJoin, direct.value)); + + root.replaceChildren( + h("div", { class: "row", style: "justify-content:space-between;flex-wrap:wrap" }, h("h1", {}, "Server"), + h("div", { class: "row", style: "min-width:320px" }, search, refresh)), + list, + h("div", { class: "card" }, h("h3", {}, "Direkt verbinden"), h("div", { class: "row" }, direct, directJoin))); + + const draw = () => { + const q = search.value.trim().toLowerCase(); + const rows = serverList.filter((s) => !q || s.name.toLowerCase().includes(q) || s.address.toLowerCase().includes(q)); + if (!rows.length) { + list.replaceChildren(h("p", { class: "muted" }, serverList.length ? "Kein Server passt zur Suche." : "Gerade ist kein öffentlicher Server online.")); + return; + } + list.replaceChildren(...rows.map((s) => { + const b = h("button", { class: "btn primary small", type: "button" }, "BEITRETEN"); + b.disabled = !s.compatible || s.players >= s.maxPlayers; + b.addEventListener("click", () => join(b, s.address)); + const ping = s.ping == null ? h("span", { class: "muted" }, "–") : h("span", { class: s.ping < 80 ? "ok" : s.ping < 150 ? "warn" : "bad" }, s.ping + " ms"); + return h("div", { class: "server" }, + h("div", { class: "server-main" }, h("b", {}, s.name), + h("span", { class: "muted small" }, s.description || s.address)), + h("span", { class: "server-col" }, `${s.players}/${s.maxPlayers}`), + h("span", { class: "server-col" }, ping), + h("span", { class: "server-col muted small" }, s.compatible ? "v" + s.version : "andere Version"), + b); + })); + }; + + const load = async () => { + refresh.disabled = true; + try { serverList = await call("servers"); draw(); } + catch (e) { list.replaceChildren(h("p", { class: "error" }, e.message)); } + finally { refresh.disabled = false; } + }; + search.addEventListener("input", draw); + refresh.addEventListener("click", load); + await load(); +} + +/** Beitreten: Server merken, Spiel starten, im Spiel automatisch verbinden. */ +function join(button, address) { + if (!address.trim()) { toast("Adresse eingeben", true); return; } + busy(button, async () => { + await call("join", { address: address.trim() }); + toast("Bus-Simulator 2012 startet und verbindet mit " + address.trim() + " …"); + }); +} + async function renderNews(root) { let list; try { list = await call("releases"); } diff --git a/ui/index.html b/ui/index.html index cb961d4..c52ffea 100644 --- a/ui/index.html +++ b/ui/index.html @@ -67,6 +67,7 @@