From 8a1329f5fae873a9b21e68966383b7c5aee70e16 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 19:30:20 +0100 Subject: [PATCH] 0.5.0: Lobby ueber das BSO-Relay (ohne Portfreigabe) - Server relay: verbindet per WebSocket ueber eine lokale Bruecke (RelayClient) - Ticket mit Lobby, Anzeige 'Lobby von ...' - Nach Szenenwechsel Mitspieler-Modelle erst nach 3 s neu anlegen Co-Authored-By: Claude Opus 5.5 --- BSO.csproj | 2 +- Bootstrap.cs | 2 +- CHANGELOG.md | 6 ++- Config.cs | 6 ++- Multiplayer.cs | 28 ++++++++-- PositionHud.cs | 6 +-- README.md | 16 +++--- Shared/RelayClient.cs | 116 ++++++++++++++++++++++++++++++++++++++++++ Shared/Ticket.cs | 9 +++- 9 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 Shared/RelayClient.cs diff --git a/BSO.csproj b/BSO.csproj index 14aacd7..165a19b 100644 --- a/BSO.csproj +++ b/BSO.csproj @@ -5,7 +5,7 @@ latest BSO BSO - 0.4.0 + 0.5.0 x64 true disable diff --git a/Bootstrap.cs b/Bootstrap.cs index 2243ceb..f4ba9d5 100644 --- a/Bootstrap.cs +++ b/Bootstrap.cs @@ -19,7 +19,7 @@ namespace BSO /// public static unsafe class Bootstrap { - public const string ModVersion = "0.4.0"; + public const string ModVersion = "0.5.0"; static HostApi* host; static int frameErrors; diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b84a6..b31e725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,14 @@ Neue Version: hier einen Abschnitt `## ` anlegen, `` in `BSO.csproj` und `Bootstrap.ModVersion` erhöhen, dann `.\build.ps1 -Release`. +## 0.5.0 +- Lobby: mit Freunden auf dem Lobby-Server spielen - ueber die BSO-Website, ohne Portfreigabe +- Anzeige "Lobby von ..." statt der Adresse + ## 0.4.0 - Mitspieler in der Welt: Bus bzw. Spielfigur mit Namensschild und Entfernung, weich bewegt - BSO-Konto: ohne Start über den Launcher kein Multiplayer, der Name im Spiel kommt aus dem Konto -- Beitreten über den Server-Browser im Launcher (verbindet automatisch) +- Beitreten über den Launcher (verbindet automatisch) ## 0.2.0 - Mitspieler-Liste: Name, zu Fuß/Bus, Entfernung, Tempo diff --git a/Config.cs b/Config.cs index 56e79f5..48bf9d0 100644 --- a/Config.cs +++ b/Config.cs @@ -9,8 +9,12 @@ namespace BSO { /// Name, den die anderen sehen. public string Name { get; set; } = Environment.UserName; - /// Server als host oder host:port. + /// Server als host oder host:port - oder relay:<Lobby-ID> (Server der Lobby im BSO-Relay). public string Server { get; set; } = "127.0.0.1:7777"; + /// Anzeige fuer den Server, z.B. "Lobby von MrMaho" (schreibt der Launcher). + public string ServerLabel { get; set; } + /// BSO-Relay (Lobby-Server) - schreibt der Launcher. + public string Relay { get; set; } = "https://bso-relay.mbm.wtf"; /// Beim Spielstart automatisch verbinden (sonst F8). public bool AutoConnect { get; set; } /// Spiel-Ticket der BSO-Website - schreibt der Launcher bei SPIELEN hinein (24 h gueltig). diff --git a/Multiplayer.cs b/Multiplayer.cs index f8ec5d7..6cf1765 100644 --- a/Multiplayer.cs +++ b/Multiplayer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; using BSO.Net; @@ -66,7 +67,7 @@ namespace BSO } } - const int ConnectTimeoutMs = 5000; + const int ConnectTimeoutMs = 5000, RelayTimeoutMs = 20000; // Relay: Website + Antwort des Lobby-Servers const int SendIntervalMs = 100, PingIntervalMs = 2000; public static NetStatus Status { get; private set; } @@ -125,6 +126,8 @@ namespace BSO } public static string ServerAddress => Config.Current.Server; + /// Fuer die Anzeige: "Lobby von ..." statt relay:<id>. + public static string ServerDisplay => Config.Current.ServerLabel ?? (ServerAddress.StartsWith("relay:") ? "Lobby" : ServerAddress); /// /// Gueltiges Spiel-Ticket vom Launcher? Ohne gibt es keinen Multiplayer - der Server prueft es zusaetzlich (die Mod @@ -152,11 +155,26 @@ namespace BSO Bootstrap.Log("Nicht verbunden: " + problem); return; } - (string host, int port) = ParseAddress(ServerAddress); - Bootstrap.Log($"Verbinde mit {host}:{port} ..."); LastError = ""; tcp = new TcpClient(); - connectTask = tcp.ConnectAsync(host, port); + if (ServerAddress.StartsWith("relay:")) + { + // Server der Lobby: laeuft im BSO-Relay, verbunden ueber eine lokale Bruecke - keine Portfreigabe noetig + string lobby = ServerAddress.Substring(6), relay = Config.Current.Relay, ticket = Config.Current.Ticket; + Bootstrap.Log($"Verbinde ueber {relay} mit Lobby {lobby} ..."); + TcpClient client = tcp; + connectTask = Task.Run(async () => + { + int port = await RelayClient.JoinAsync(relay, lobby, ticket, CancellationToken.None); + await client.ConnectAsync("127.0.0.1", port); + }); + } + else + { + (string host, int port) = ParseAddress(ServerAddress); + Bootstrap.Log($"Verbinde mit {host}:{port} ..."); + connectTask = tcp.ConnectAsync(host, port); + } connectClock.Restart(); Status = NetStatus.Connecting; } @@ -165,7 +183,7 @@ namespace BSO { if (!connectTask.IsCompleted) { - if (connectClock.ElapsedMilliseconds > ConnectTimeoutMs) Fail("Server antwortet nicht"); + if (connectClock.ElapsedMilliseconds > (ServerAddress.StartsWith("relay:") ? RelayTimeoutMs : ConnectTimeoutMs)) Fail("Server antwortet nicht"); return; } if (connectTask.IsFaulted || !tcp.Connected) diff --git a/PositionHud.cs b/PositionHud.cs index dcfd0b1..7cf5026 100644 --- a/PositionHud.cs +++ b/PositionHud.cs @@ -63,16 +63,16 @@ namespace BSO text.Add(Multiplayer.AccountProblem); return; case Multiplayer.NetStatus.Offline: - text.Add($"Offline - [F8] verbinden mit {Multiplayer.ServerAddress} als {Multiplayer.PlayerName}"); + text.Add($"Offline - [F8] verbinden mit {Multiplayer.ServerDisplay} als {Multiplayer.PlayerName}"); if (Multiplayer.LastError.Length > 0) text.Add(" " + Multiplayer.LastError); return; case Multiplayer.NetStatus.Connecting: - text.Add($"Verbinde mit {Multiplayer.ServerAddress} ..."); + text.Add($"Verbinde mit {Multiplayer.ServerDisplay} ..."); return; } if (!Multiplayer.Welcomed) { - text.Add($"Verbinde mit {Multiplayer.ServerAddress} ... warte auf Server"); + text.Add($"Verbinde mit {Multiplayer.ServerDisplay} ... warte auf Server"); return; } diff --git a/README.md b/README.md index 6206ff8..fb23dc6 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ Multiplayer-Mod für **Bus-Simulator 2012** (European Bus Simulator 2012, TML-St eigenständiger Loader im Spielordner, die Mod selbst in C# (.NET 10). - Mod: dieses Repository -- Dedizierter Server: [BSO-Server](https://git.mbm.wtf/MrMaho/BSO-Server) +- Lobby-Server (ohne Portfreigabe): [BSO-Relay](https://git.mbm.wtf/MrMaho/BSO-Relay) - Launcher (installiert und aktualisiert die Mod): [BSO-Launcher](https://git.mbm.wtf/MrMaho/BSO-Launcher) -- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website) +- Website, Konten, Freunde, Lobbys, Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website) -**Stand:** Spieler verbinden sich per IP mit einem Server und sehen die anderen als Liste (Name, zu Fuß/Bus, -Entfernung, Tempo). Als Nächstes kommen sichtbare Busse der Mitspieler in der Welt. +**Stand:** Freunde spielen zusammen in einer Lobby – der Server läuft im BSO-Relay, die Mod verbindet sich per WebSocket, +niemand braucht eine Portfreigabe. Mitspieler erscheinen als Bus bzw. Spielfigur mit Namensschild in der Welt. ## Wie es funktioniert @@ -24,7 +24,8 @@ Entfernung, Tempo). Als Nächstes kommen sichtbare Busse der Mitspieler in der W | `Config.cs` | `BSO.json` (Name, Server, AutoConnect) | – | | `PositionHud.cs` | Anzeige im Spiel | `Overlays.cs` | | `BorderlessWindow.cs` | Vollbild im Fenster (F11) | – | -| `Shared\*.cs` | Protokoll, `PacketReader`, `Connection`/`FrameParser` – **gleiche Dateien wie in BSO-Server** | `NetSession.cs` | +| `RemotePlayers.cs`, `Models.cs` | Mitspieler als Bus/Spielfigur in der Welt, Namensschild | `RemotePlayer.cs` | +| `Shared\*.cs` | Protokoll, `Connection`, `Ticket`, `RelayClient` (WebSocket-Brücke) – **gleiche Dateien wie in BSO-Relay** | `NetSession.cs` | - **Bild-Takt:** Das Spiel fragt die Tastatur mehrmals pro Bild über DirectInput ab. Der Loader hängt sich dort ein und ruft `Bootstrap.OnFrame` im Hauptthread auf. Gerechnet wird, wenn die Engine-Uhr weiterläuft; Text wird bei @@ -82,5 +83,6 @@ Protokoll: `Bin_High_Win64\BSO\BSO_Loader.txt` 1. ~~Mod lädt, Busposition anzeigen~~ 2. ~~Netzwerk: Server + Positionen austauschen, Mitspieler als Text~~ -3. Mitspieler-Busse in der Welt (`VisGame_cl::CreateEntity`), Namensschild mit `VisMessage_cl::DrawMessage3D` -4. Chat, Konsole, Steam-P2P, Serverliste +3. ~~Mitspieler-Busse in der Welt, Namensschild~~ +4. ~~Konten, Freunde, Lobby ohne Portfreigabe (BSO-Relay)~~ +5. Direkt ins Spiel laden, Chat, Türen/Lichter diff --git a/Shared/RelayClient.cs b/Shared/RelayClient.cs new file mode 100644 index 0000000..7680b0e --- /dev/null +++ b/Shared/RelayClient.cs @@ -0,0 +1,116 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace BSO.Net +{ + /// + /// BSO-Relay (Lobby-Server ohne Portfreigabe): eine TCP-Verbindung wird ueber einen WebSocket zum Relay + /// durchgereicht. Die Mod spricht weiter ganz normal TCP (Connection) - nur eben mit 127.0.0.1, die Bruecke + /// schickt alles ueber das Relay. Angemeldet wird mit dem Spiel-Ticket der Lobby (Kopfzeile X-BSO-Ticket). + /// + public static class RelayClient + { + /// https://site/relay/... -> wss://site/relay/... + public static Uri WsUri(string master, string pathAndQuery) + { + string b = master.TrimEnd('/'); + if (b.StartsWith("https://")) b = "wss://" + b.Substring(8); + else if (b.StartsWith("http://")) b = "ws://" + b.Substring(7); + return new Uri(b + pathAndQuery); + } + + public static async Task ConnectAsync(string master, string pathAndQuery, string ticket, CancellationToken ct) + { + var ws = new ClientWebSocket(); + ws.Options.SetRequestHeader("X-BSO-Ticket", ticket ?? ""); + ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); + try + { + await ws.ConnectAsync(WsUri(master, pathAndQuery), ct); + return ws; + } + catch (WebSocketException e) + { + ws.Dispose(); + string msg = e.Message ?? ""; + if (msg.Contains("404")) throw new Exception("Der Server der Lobby laeuft gerade nicht"); + if (msg.Contains("403")) throw new Exception("Kein Zugang zu dieser Lobby - im Launcher neu auf SPIELEN druecken"); + throw new Exception("BSO-Relay nicht erreichbar: " + msg); + } + } + + /// + /// Mitspieler: mit dem Lobby-Server verbinden und eine lokale Bruecke oeffnen. Gibt den Port auf 127.0.0.1 zurueck, + /// mit dem man sich dann normal per TCP verbindet. Die Bruecke nimmt genau eine Verbindung an. + /// + public static async Task JoinAsync(string master, string lobbyId, string ticket, CancellationToken ct) + { + ClientWebSocket ws = await ConnectAsync(master, "/join?lobby=" + Uri.EscapeDataString(lobbyId), ticket, ct); + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + try + { + using var accept = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using TcpClient tcp = await listener.AcceptTcpClientAsync(accept.Token); + listener.Stop(); + await BridgeAsync(tcp, ws); + } + catch { } + finally + { + try { listener.Stop(); } catch { } + ws.Dispose(); + } + }); + return port; + } + + /// Daten zwischen TCP und WebSocket in beide Richtungen, bis eine Seite trennt. + public static async Task BridgeAsync(TcpClient tcp, WebSocket ws) + { + tcp.NoDelay = true; + NetworkStream stream = tcp.GetStream(); + using var cts = new CancellationTokenSource(); + + async Task TcpToWs() + { + var buffer = new byte[64 * 1024]; + try + { + int n; + while ((n = await stream.ReadAsync(buffer, cts.Token)) > 0) + await ws.SendAsync(new ArraySegment(buffer, 0, n), WebSocketMessageType.Binary, true, cts.Token); + } + catch { } + cts.Cancel(); + } + + async Task WsToTcp() + { + var buffer = new byte[64 * 1024]; + try + { + while (!cts.IsCancellationRequested) + { + var r = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + if (r.MessageType == WebSocketMessageType.Close) break; + await stream.WriteAsync(buffer, 0, r.Count, cts.Token); + } + } + catch { } + cts.Cancel(); + } + + await Task.WhenAll(TcpToWs(), WsToTcp()); + try { tcp.Close(); } catch { } + try { if (ws.State == WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); } catch { } + } + } +} diff --git a/Shared/Ticket.cs b/Shared/Ticket.cs index 90093dc..78dd92c 100644 --- a/Shared/Ticket.cs +++ b/Shared/Ticket.cs @@ -6,7 +6,7 @@ using System.Text.Json; namespace BSO.Net { /// - /// Spiel-Ticket der BSO-Website: base64url(JSON {u, id, iat, exp}) + "." + base64url(RSA-SHA256-Signatur). Der Launcher + /// Spiel-Ticket der BSO-Website: base64url(JSON {u, id, iat, exp, lb?, ln?}) + "." + base64url(RSA-SHA256-Signatur). Der Launcher /// holt es beim Spielen und schreibt es in BSO.json, die Mod schickt es im Hello mit. Die Mod liest es nur (Name, Ablauf), /// pruefen kann es nur der Server mit dem oeffentlichen Schluessel der Website (Verify) - wer die Mod veraendert, kommt so /// trotzdem nicht ohne gueltiges Konto auf einen Server. @@ -16,6 +16,9 @@ namespace BSO.Net public string Username { get; private set; } public string AccountId { get; private set; } public DateTime Expires { get; private set; } + /// Lobby, fuer die das Ticket gilt (null = keine) und ihr Name ("Lobby von ..."). + public string Lobby { get; private set; } + public string LobbyName { get; private set; } public bool Expired => DateTime.UtcNow >= Expires; /// Inhalt lesen, ohne die Signatur zu pruefen. null = kein Ticket / kaputt. @@ -32,7 +35,9 @@ namespace BSO.Net { Username = r.GetProperty("u").GetString(), AccountId = r.GetProperty("id").GetString(), - Expires = DateTimeOffset.FromUnixTimeSeconds(r.GetProperty("exp").GetInt64()).UtcDateTime + Expires = DateTimeOffset.FromUnixTimeSeconds(r.GetProperty("exp").GetInt64()).UtcDateTime, + Lobby = r.TryGetProperty("lb", out var lb) ? lb.GetString() : null, + LobbyName = r.TryGetProperty("ln", out var ln) ? ln.GetString() : null }; } catch { return null; }