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:
+1
-1
@@ -8,7 +8,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>BSO.Launcher</RootNamespace>
|
||||
<AssemblyName>BSO-Launcher</AssemblyName>
|
||||
<Version>0.1.1</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<Company>MBM.wtf</Company>
|
||||
<Product>BSO Launcher</Product>
|
||||
<Description>BSO Launcher - Bus Simulator Online</Description>
|
||||
|
||||
+43
-3
@@ -19,9 +19,9 @@ public sealed class MainForm : Form
|
||||
readonly Settings settings = Settings.Load();
|
||||
|
||||
/// <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();
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -71,6 +71,20 @@ public static class Api
|
||||
|
||||
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)
|
||||
{
|
||||
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
|
||||
|
||||
+3
-2
@@ -85,8 +85,8 @@ public static class Game
|
||||
LauncherLog.Info("BSO installiert nach " + bin);
|
||||
}
|
||||
|
||||
/// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden.</summary>
|
||||
public static void WriteModConfig(string folder, string? name, string server, bool autoConnect)
|
||||
/// <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, 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 }));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
</div>
|
||||
<nav id="nav">
|
||||
<button type="button" data-page="home" class="on"><span class="nav-icon"></span>Spielen</button>
|
||||
<button type="button" data-page="servers"><span class="nav-icon"></span>Server</button>
|
||||
<button type="button" data-page="news"><span class="nav-icon"></span>Neuigkeiten</button>
|
||||
<button type="button" data-page="settings"><span class="nav-icon"></span>Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user