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 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 18:10:55 +01:00
co-authored by Claude Opus 5.5
parent 077e39fa8a
commit fb8d6d6844
8 changed files with 174 additions and 7 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>BSO.Launcher</RootNamespace> <RootNamespace>BSO.Launcher</RootNamespace>
<AssemblyName>BSO-Launcher</AssemblyName> <AssemblyName>BSO-Launcher</AssemblyName>
<Version>0.1.1</Version> <Version>0.2.0</Version>
<Company>MBM.wtf</Company> <Company>MBM.wtf</Company>
<Product>BSO Launcher</Product> <Product>BSO Launcher</Product>
<Description>BSO Launcher - Bus Simulator Online</Description> <Description>BSO Launcher - Bus Simulator Online</Description>
+43 -3
View File
@@ -19,9 +19,9 @@ public sealed class MainForm : Form
readonly Settings settings = Settings.Load(); readonly Settings settings = Settings.Load();
/// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary> /// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary>
static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "installLauncherUpdate", "uninstall" }; static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "join", "installLauncherUpdate", "uninstall" };
readonly HashSet<string> running = new(); readonly HashSet<string> running = new();
static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases" }; static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases", "servers" };
bool updateInstalled; bool updateInstalled;
static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
@@ -288,6 +288,18 @@ public sealed class MainForm : Form
return await InstallMod(); return await InstallMod();
case "launch": case "launch":
return await 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": case "releases":
{ {
var r = await Api.Releases(); var r = await Api.Releases();
@@ -363,6 +375,21 @@ public sealed class MainForm : Form
}; };
} }
/// <summary>Serverliste der Website, jeder Server direkt angefragt (Ping, aktuelle Spielerzahl). Nicht erreichbar = ohne Ping.</summary>
static async Task<object> 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<object> InstallMod() async Task<object> InstallMod()
{ {
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen"); 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"); 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(); Game.Launch();
LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})"); LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})");
if (!settings.KeepOpen) if (!settings.KeepOpen)
+14
View File
@@ -71,6 +71,20 @@ public static class Api
public static Task<Result> Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token); public static Task<Result> Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token);
/// <summary>Spiel-Ticket fuer die Mod (signiert, 24 h) -> { ticket, username, expiresUtc }</summary>
public static Task<Result> 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);
/// <summary>Oeffentliche, erreichbare Server (Serverliste der Website).</summary>
public static async Task<List<ServerEntry>> 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<List<ServerEntry>>() ?? new List<ServerEntry>();
}
public static async Task<Release?> Latest(string product) public static async Task<Release?> Latest(string product)
{ {
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null); var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
+3 -2
View File
@@ -85,8 +85,8 @@ public static class Game
LauncherLog.Info("BSO installiert nach " + bin); LauncherLog.Info("BSO installiert nach " + bin);
} }
/// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden.</summary> /// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden, Spiel-Ticket.</summary>
public static void WriteModConfig(string folder, string? name, string server, bool autoConnect) public static void WriteModConfig(string folder, string? name, string server, bool autoConnect, string? ticket)
{ {
string file = Path.Combine(Bin(folder), "BSO", "BSO.json"); string file = Path.Combine(Bin(folder), "BSO", "BSO.json");
JsonObject json; JsonObject json;
@@ -95,6 +95,7 @@ public static class Game
if (!string.IsNullOrEmpty(name)) json["Name"] = name; if (!string.IsNullOrEmpty(name)) json["Name"] = name;
json["Server"] = server; json["Server"] = server;
json["AutoConnect"] = autoConnect; json["AutoConnect"] = autoConnect;
json["Ticket"] = ticket;
Directory.CreateDirectory(Path.GetDirectoryName(file)!); Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
} }
+38
View File
@@ -0,0 +1,38 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text;
namespace BSO.Launcher;
/// <summary>
/// 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).
/// </summary>
public static class ServerPing
{
public sealed record Info(int Ping, string Name, string Version, int Players, int MaxPlayers);
public static async Task<Info?> 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; }
}
}
+10
View File
@@ -146,6 +146,16 @@ nav button.on { background: var(--panel-2); color: var(--text); }
.dot.bad { background: var(--danger); } .dot.bad { background: var(--danger); }
.row { display: flex; gap: 8px; align-items: center; } .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 */ /* ---------------------------------------------------------------- Neuigkeiten */
.release { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 6px; } .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; } .release header { display: flex; align-items: baseline; gap: 10px; }
+64 -1
View File
@@ -140,9 +140,12 @@ function showPage(name) {
document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name)); document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name));
const root = $("page"); const root = $("page");
root.replaceChildren(h("p", { class: "muted" }, "Laden …")); 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) // Fortschritt (Downloads)
let progressBox = null; let progressBox = null;
on("progress", (m) => { 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)); 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) { async function renderNews(root) {
let list; let list;
try { list = await call("releases"); } try { list = await call("releases"); }
+1
View File
@@ -67,6 +67,7 @@
</div> </div>
<nav id="nav"> <nav id="nav">
<button type="button" data-page="home" class="on"><span class="nav-icon">&#xE768;</span>Spielen</button> <button type="button" data-page="home" class="on"><span class="nav-icon">&#xE768;</span>Spielen</button>
<button type="button" data-page="servers"><span class="nav-icon">&#xE968;</span>Server</button>
<button type="button" data-page="news"><span class="nav-icon">&#xE789;</span>Neuigkeiten</button> <button type="button" data-page="news"><span class="nav-icon">&#xE789;</span>Neuigkeiten</button>
<button type="button" data-page="settings"><span class="nav-icon">&#xE713;</span>Einstellungen</button> <button type="button" data-page="settings"><span class="nav-icon">&#xE713;</span>Einstellungen</button>
</nav> </nav>