commit cda31cd0701ec67662d054080610e89befdfbfc2 Author: MrMaho Date: Wed Oct 28 17:26:24 2026 +0100 Erste Version Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08a4571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +dist/ +publish/ +.vs/ +*.user diff --git a/BSO.Server.csproj b/BSO.Server.csproj new file mode 100644 index 0000000..5ba3ea5 --- /dev/null +++ b/BSO.Server.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + disable + enable + BSO.Server + BSO.Server + 0.2.0 + BSO Dedicated Server + true + + logo.ico + false + + + + + + + diff --git a/GameServer.cs b/GameServer.cs new file mode 100644 index 0000000..d7e0cbd --- /dev/null +++ b/GameServer.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Net.Sockets; +using BSO.Net; + +namespace BSO.Server; + +/// +/// Dedizierter Server: nimmt Spieler an (Hello -> Welcome), merkt sich den letzten Zustand jedes Spielers und reicht +/// ihn an alle anderen weiter. Laeuft in einer einzigen Schleife (Tick) - alle Verbindungen werden dort abgefragt, +/// es braucht keine Sperren. +/// +public class GameServer +{ + public const string Version = "0.2.0"; + static readonly TimeSpan HelloTimeout = TimeSpan.FromSeconds(10); + static readonly TimeSpan SilenceTimeout = TimeSpan.FromSeconds(30); // Mod pingt alle 2 s + + class Client + { + public int Id; + public string Name; + public string Address; + public Connection Conn; + public bool Welcomed; + public DateTime Connected, LastMessage; + public PlayerState State; + public bool HasState; + } + + readonly TcpListener listener; + readonly Dictionary clients = new(); + int nextId = 1; + + public string Name { get; } + public int MaxPlayers { get; } + public TestBot Bot { get; private set; } + + public GameServer(int port, string name, int maxPlayers) + { + Name = name; + MaxPlayers = maxPlayers; + listener = new TcpListener(IPAddress.Any, port); + listener.Start(); + Log($"BSO Server {Version} (Protokoll {Protocol.Version}) auf Port {port} - \"{name}\", max. {maxPlayers} Spieler"); + } + + public static void Log(string line) => Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + " " + line); + + public void Tick() + { + while (listener.Pending()) + { + TcpClient tcp = listener.AcceptTcpClient(); + var c = new Client + { + Id = nextId++, + Address = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?", + Conn = new Connection(tcp), + Connected = DateTime.UtcNow, + LastMessage = DateTime.UtcNow + }; + clients[c.Id] = c; + } + + DateTime now = DateTime.UtcNow; + foreach (Client c in clients.Values.ToList()) + { + foreach (Message m in c.Conn.Poll()) + { + c.LastMessage = now; + try { Handle(c, m); } + catch (Exception e) { c.Conn.Close("Ungueltige Nachricht " + m.Type + ": " + e.Message); } + } + if (!c.Welcomed && now - c.Connected > HelloTimeout) c.Conn.Close("Kein Hello"); + if (now - c.LastMessage > SilenceTimeout) c.Conn.Close("Zeitueberschreitung"); + if (c.Conn.IsClosed) Remove(c); + } + + if (Bot != null && Bot.Tick(Players().Where(p => p.HasState).Select(p => p.State))) + Broadcast(MsgType.PlayerState, StatePayload(Bot.Id, Bot.State), except: 0); + } + + void Handle(Client c, Message m) + { + BinaryReader r = m.Reader(); + switch (m.Type) + { + case MsgType.Info: + c.Conn.Send(MsgType.InfoReply, Wire.Build(w => + { + w.Write(Name); + w.Write(Version); + w.Write(Players().Count()); + w.Write(MaxPlayers); + })); + break; + + case MsgType.Hello: + if (c.Welcomed) return; + int protocol = r.ReadInt32(); + string modVersion = r.ReadString(); + string name = Clean(r.ReadString(), c.Id); + if (protocol != Protocol.Version) + { + Reject(c, $"Versionen passen nicht: Server Protokoll {Protocol.Version}, Mod {modVersion} hat Protokoll {protocol}"); + return; + } + if (Players().Count() >= MaxPlayers) + { + Reject(c, $"Server ist voll ({MaxPlayers} Spieler)"); + return; + } + c.Name = name; + c.Welcomed = true; + var others = Others(c.Id).ToList(); + c.Conn.Send(MsgType.Welcome, Wire.Build(w => + { + w.Write(c.Id); + w.Write(Name); + w.Write(others.Count); + foreach ((int id, string n, _, _) in others) { w.Write(id); w.Write(n); } + })); + // Letzten Stand der anderen gleich mitschicken, damit sie nicht erst beim naechsten Paket auftauchen + foreach ((int id, _, bool has, PlayerState s) in others) + if (has) c.Conn.Send(MsgType.PlayerState, StatePayload(id, s)); + Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(c.Id); w.Write(c.Name); }), except: c.Id); + Log($"{c.Name} (#{c.Id}, {c.Address}, Mod {modVersion}) ist beigetreten - {Players().Count()} Spieler"); + break; + + case MsgType.PlayerState: + if (!c.Welcomed) return; + c.State = PlayerState.Read(r); + c.HasState = true; + Broadcast(MsgType.PlayerState, StatePayload(c.Id, c.State), except: c.Id); + break; + + case MsgType.Ping: + c.Conn.Send(MsgType.Pong, m.Payload); + break; + } + } + + void Reject(Client c, string reason) + { + Log($"{c.Address} abgelehnt: {reason}"); + c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write(reason))); + c.Conn.Close(reason); + } + + void Remove(Client c) + { + clients.Remove(c.Id); + if (!c.Welcomed) return; + Broadcast(MsgType.PlayerLeft, Wire.Build(w => w.Write(c.Id)), except: c.Id); + Log($"{c.Name} (#{c.Id}) ist gegangen: {c.Conn.CloseReason} - {Players().Count()} Spieler"); + } + + IEnumerable Players() => clients.Values.Where(c => c.Welcomed && !c.Conn.IsClosed); + + /// Alle anderen Spieler inkl. Testbot: (ID, Name, hat Zustand, Zustand). + IEnumerable<(int, string, bool, PlayerState)> Others(int self) + { + foreach (Client p in Players()) + if (p.Id != self) yield return (p.Id, p.Name, p.HasState, p.State); + if (Bot != null) yield return (Bot.Id, Bot.Name, Bot.HasState, Bot.State); + } + + void Broadcast(MsgType type, byte[] payload, int except) + { + foreach (Client p in Players()) + if (p.Id != except) p.Conn.Send(type, payload); + } + + static byte[] StatePayload(int id, PlayerState s) => Wire.Build(w => { w.Write(id); s.Write(w); }); + + static string Clean(string name, int id) + { + name = new string((name ?? "").Where(ch => !char.IsControl(ch)).ToArray()).Trim(); + if (name.Length > 24) name = name.Substring(0, 24); + return name.Length > 0 ? name : "Spieler" + id; + } + + // --------------------------------------------------------------------------------------------------------------- + // Konsole + + public void Command(string line) + { + string[] parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return; + switch (parts[0].ToLowerInvariant()) + { + case "help": + Log("Befehle: status, bot, kick [grund], stop"); + break; + case "status": + case "players": + Log($"{Players().Count()}/{MaxPlayers} Spieler" + (Bot != null ? " + Testbot" : "")); + foreach (Client p in Players()) + Log($" #{p.Id} {p.Name} ({p.Address}) " + (p.HasState ? $"{p.State.Mode} bei {p.State.X / 100:0} / {p.State.Y / 100:0} m, {p.State.Speed * 0.036f:0} km/h" : "noch keine Position")); + break; + case "bot": + if (Bot == null) AddBot(); + else + { + Broadcast(MsgType.PlayerLeft, Wire.Build(w => w.Write(Bot.Id)), except: 0); + Bot = null; + Log("Testbot entfernt"); + } + break; + case "kick": + string[] args = parts.Length > 1 ? parts[1].Split(' ', 2) : Array.Empty(); + if (args.Length > 0 && int.TryParse(args[0], out int kickId) && clients.TryGetValue(kickId, out Client k)) + { + string reason = args.Length > 1 ? args[1] : "Vom Server entfernt"; + Reject(k, reason); + } + else Log("kick [grund] - IDs siehe status"); + break; + default: + Log("Unbekannter Befehl - help"); + break; + } + } + + public void AddBot() + { + Bot = new TestBot(nextId++); + Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(Bot.Id); w.Write(Bot.Name); }), except: 0); + Log($"Testbot #{Bot.Id} da - faehrt Kreise um den ersten Spieler, der im Spiel ist"); + } + + public void Stop() + { + foreach (Client c in clients.Values) c.Conn.Close("Server beendet"); + listener.Stop(); + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f931485 --- /dev/null +++ b/Program.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Sockets; +using BSO.Net; +using BSO.Server; + +// BSO.Server [--port 7777] [--name "Mein Server"] [--max 8] [--bot] +// BSO.Server --probe host[:port] (Test: verbindet sich wie die Mod und zeigt, was ankommt) + +int port = Protocol.DefaultPort, max = 8; +string name = "BSO Server", probe = null; +bool bot = false; +for (int i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--port": port = int.Parse(args[++i]); break; + case "--name": name = args[++i]; break; + case "--max": max = int.Parse(args[++i]); break; + case "--bot": bot = true; break; + case "--probe": probe = args[++i]; break; + default: + Console.WriteLine("BSO.Server [--port 7777] [--name \"Name\"] [--max 8] [--bot] | --probe host[:port]"); + return 1; + } +} + +if (probe != null) return Probe(probe); + +var server = new GameServer(port, name, max); +if (bot) server.AddBot(); +GameServer.Log("Befehle: help, status, bot, kick , stop"); + +var commands = new ConcurrentQueue(); +new Thread(() => +{ + string line; + while ((line = Console.ReadLine()) != null) commands.Enqueue(line); +}) { IsBackground = true, Name = "Konsole" }.Start(); + +bool running = true; +Console.CancelKeyPress += (_, e) => { e.Cancel = true; running = false; }; +while (running) +{ + server.Tick(); + while (commands.TryDequeue(out string cmd)) + { + if (cmd.Trim().Equals("stop", StringComparison.OrdinalIgnoreCase)) running = false; + else server.Command(cmd); + } + Thread.Sleep(5); +} +server.Stop(); +GameServer.Log("Server beendet"); +return 0; + +/// Verbindet sich wie die Mod (Info, dann Hello als "Probe") und zeigt 5 s lang alles, was ankommt. +static int Probe(string target) +{ + string[] hp = target.Split(':'); + int p = hp.Length > 1 ? int.Parse(hp[1]) : Protocol.DefaultPort; + var tcp = new TcpClient(); + if (!tcp.ConnectAsync(hp[0], p).Wait(5000)) { Console.WriteLine("Keine Verbindung zu " + target); return 2; } + var conn = new Connection(tcp); + conn.Send(MsgType.Info, null); + conn.Send(MsgType.Hello, Wire.Build(w => { w.Write(Protocol.Version); w.Write("probe"); w.Write("Probe"); })); + conn.Send(MsgType.Ping, Wire.Build(w => w.Write(Stopwatch.GetTimestamp()))); + // Eigene Position, damit der Testbot losfaehrt + var me = new PlayerState { Mode = PlayerMode.InBus, X = 70000, Y = 30000, Z = 760 }; + + var watch = Stopwatch.StartNew(); + int states = 0; + while (watch.Elapsed.TotalSeconds < 5 && !conn.IsClosed) + { + conn.Send(MsgType.PlayerState, Wire.Build(w => me.Write(w))); + foreach (Message m in conn.Poll()) + { + BinaryReader r = m.Reader(); + switch (m.Type) + { + case MsgType.InfoReply: + Console.WriteLine($"Info: \"{r.ReadString()}\" Version {r.ReadString()}, {r.ReadInt32()}/{r.ReadInt32()} Spieler"); + break; + case MsgType.Welcome: + int id = r.ReadInt32(); + string server = r.ReadString(); + int n = r.ReadInt32(); + var list = Enumerable.Range(0, n).Select(_ => $"#{r.ReadInt32()} {r.ReadString()}").ToList(); + Console.WriteLine($"Welcome: ich bin #{id} auf \"{server}\", andere: {(n == 0 ? "-" : string.Join(", ", list))}"); + break; + case MsgType.Reject: + Console.WriteLine("Abgelehnt: " + r.ReadString()); + break; + case MsgType.Pong: + Console.WriteLine($"Ping: {Stopwatch.GetElapsedTime(r.ReadInt64()).TotalMilliseconds:0.0} ms"); + break; + case MsgType.PlayerJoined: + Console.WriteLine($"Beigetreten: #{r.ReadInt32()} {r.ReadString()}"); + break; + case MsgType.PlayerLeft: + Console.WriteLine($"Gegangen: #{r.ReadInt32()}"); + break; + case MsgType.PlayerState: + int who = r.ReadInt32(); + PlayerState s = PlayerState.Read(r); + if (states++ % 10 == 0) + Console.WriteLine($"Zustand #{who}: {s.Mode} {s.X / 100:0.0} / {s.Y / 100:0.0} / {s.Z / 100:0.0} m, {s.Speed * 0.036f:0} km/h, {s.DistanceTo(me) / 100:0.0} m entfernt"); + break; + } + } + Thread.Sleep(100); + } + Console.WriteLine($"{states} Zustaende in 5 s" + (conn.IsClosed ? " - getrennt: " + conn.CloseReason : "")); + conn.Close("Probe fertig"); + return 0; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fd1fde --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# BSO Server + +Dedizierter Server für **BSO – Bus Simulator Online** (Multiplayer-Mod für Bus-Simulator 2012). + +- Mod: [BSO-Mod](https://git.mbm.wtf/MrMaho/BSO-Mod) +- Launcher: [BSO-Launcher](https://git.mbm.wtf/MrMaho/BSO-Launcher) +- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website) + +Nimmt Spieler an (Hello → Welcome), merkt sich den letzten Zustand jedes Spielers und reicht ihn an alle anderen +weiter. Eine einzige Schleife fragt alle Verbindungen ab – ohne Sperren. Protokoll wie bei MSCO: +`[int Länge][byte Typ][Daten]` über TCP. + +`Shared\*.cs` (Protokoll, `PacketReader`, `Connection`) sind **dieselben Dateien wie in BSO-Mod** – bei Änderungen in +beiden Repos gleich halten und bei inkompatiblen Änderungen `Protocol.Version` erhöhen. + +## Starten + +Voraussetzung: .NET 10 Runtime. + +```powershell +BSO.Server.exe --bot # Port 7777, mit Testbot +BSO.Server.exe --port 7777 --name "Mein Server" --max 8 +BSO.Server.exe --probe host[:port] # Test: verbindet sich wie die Mod, zeigt was ankommt +``` + +Für Spieler von außen Port **7777/TCP** im Router/Firewall freigeben. + +| Konsole | | +|---|---| +| `status` | Spieler mit Position, Modus und Tempo | +| `bot` | Testbot an/aus – fährt als „Bus“ 30 km/h Kreise (20 m) um den ersten Spieler | +| `kick [grund]` | Spieler entfernen | +| `stop` | Server beenden | + +## Bauen + +```powershell +dotnet publish -c Release -o publish +.\release.ps1 -Product server -Version 0.2.0 -File BSO-Server.zip -Changelog "..." +``` diff --git a/Shared/Connection.cs b/Shared/Connection.cs new file mode 100644 index 0000000..23ea1af --- /dev/null +++ b/Shared/Connection.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Sockets; +using System.Threading; + +namespace BSO.Net +{ + /// Setzt aus einem Byte-Strom Nachrichten zusammen: [int Laenge][byte Typ][Payload]. + public class FrameParser + { + const int MaxFrame = 1024 * 1024; + + byte[] buffer = new byte[64 * 1024]; + int count; + + public void Append(byte[] data, int length) + { + EnsureCapacity(count + length); + Buffer.BlockCopy(data, 0, buffer, count, length); + count += length; + } + + public void Parse(List messages) + { + int offset = 0; + while (count - offset >= 5) + { + int length = BitConverter.ToInt32(buffer, offset); + if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet"); + if (count - offset - 4 < length) break; + + var payload = new byte[length - 1]; + Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length); + messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload }); + offset += 4 + length; + } + if (offset > 0) + { + Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset); + count -= offset; + } + } + + public static byte[] Frame(MsgType type, byte[] payload) + { + payload ??= Array.Empty(); + var frame = new byte[5 + payload.Length]; + int length = payload.Length + 1; // Little Endian wie BitConverter + frame[0] = (byte)length; frame[1] = (byte)(length >> 8); frame[2] = (byte)(length >> 16); frame[3] = (byte)(length >> 24); + frame[4] = (byte)type; + Buffer.BlockCopy(payload, 0, frame, 5, payload.Length); + return frame; + } + + void EnsureCapacity(int needed) + { + if (needed <= buffer.Length) return; + var bigger = new byte[Math.Max(needed, buffer.Length * 2)]; + Buffer.BlockCopy(buffer, 0, bigger, 0, count); + buffer = bigger; + } + } + + /// + /// TCP-Verbindung wie bei MSCO: Lesen ist nicht-blockierend und wird ueber Poll() abgefragt (im Spiel im Hauptthread), + /// Senden laeuft ueber eine Warteschlange und einen eigenen Thread - eine kurz volle Leitung haelt das Spiel nicht an. + /// Kleine Pakete werden dabei zusammengefasst. + /// + public class Connection + { + volatile bool closed; + volatile string closeReason; + public bool IsClosed => closed; + public string CloseReason => closeReason; + + // Mehr als das unterwegs = die Gegenseite nimmt nichts mehr an + const long MaxQueuedBytes = 16L * 1024 * 1024; + const int MaxBatchBytes = 256 * 1024; + + readonly TcpClient client; + readonly NetworkStream stream; + readonly FrameParser parser = new(); + readonly byte[] readBuffer = new byte[64 * 1024]; + readonly Queue outgoing = new(); + readonly object gate = new(); + long queuedBytes; + readonly Thread writer; + + public Connection(TcpClient client) + { + this.client = client; + client.NoDelay = true; + stream = client.GetStream(); + writer = new Thread(WriteLoop) { IsBackground = true, Name = "BSO send" }; + writer.Start(); + } + + /// Sende-Thread: wartet auf Pakete und schreibt sie (gebuendelt) in die Verbindung. + void WriteLoop() + { + var batch = new MemoryStream(MaxBatchBytes); + try + { + while (true) + { + batch.SetLength(0); + lock (gate) + { + while (outgoing.Count == 0 && !closed) Monitor.Wait(gate); + if (outgoing.Count == 0) return; // geschlossen und nichts mehr zu senden + do + { + byte[] frame = outgoing.Dequeue(); + queuedBytes -= frame.Length; + batch.Write(frame, 0, frame.Length); + } while (outgoing.Count > 0 && batch.Length + outgoing.Peek().Length <= MaxBatchBytes); + Monitor.PulseAll(gate); // Close() wartet evtl. aufs Leerwerden + } + stream.Write(batch.GetBuffer(), 0, (int)batch.Length); + } + } + catch (Exception e) + { + if (!closed) Close("Netzwerkfehler: " + e.Message); + } + } + + readonly List polled = new(); // wiederverwendet: der Aufrufer geht sie sofort durch + public List Poll() + { + var messages = polled; + messages.Clear(); + if (IsClosed) return messages; + try + { + // Verbindung von der Gegenseite geschlossen? + if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0) + { + Close("Verbindung getrennt"); + return messages; + } + while (stream.DataAvailable) + { + int read = stream.Read(readBuffer, 0, readBuffer.Length); + if (read <= 0) break; + parser.Append(readBuffer, read); + } + parser.Parse(messages); + } + catch (Exception e) + { + Close("Netzwerkfehler: " + e.Message); + } + return messages; + } + + public void Send(MsgType type, byte[] payload) + { + if (closed) return; + byte[] frame = FrameParser.Frame(type, payload); + bool tooMuch; + lock (gate) + { + tooMuch = queuedBytes + frame.Length > MaxQueuedBytes; + if (!tooMuch) + { + outgoing.Enqueue(frame); + queuedBytes += frame.Length; + Monitor.Pulse(gate); + } + } + if (tooMuch) Close("Verbindung zu langsam - zu viele Daten warten aufs Senden"); + } + + public void Close(string reason) + { + lock (gate) + { + if (closed) return; + // Was schon in der Warteschlange liegt (z.B. eine Abschiedsnachricht), kurz noch rausgehen lassen + var until = DateTime.UtcNow.AddMilliseconds(300); + while (outgoing.Count > 0 && DateTime.UtcNow < until && Thread.CurrentThread != writer) + Monitor.Wait(gate, 50); + closed = true; + closeReason = reason; + outgoing.Clear(); + queuedBytes = 0; + Monitor.PulseAll(gate); + } + try { client.Close(); } catch { } + } + } +} diff --git a/Shared/PacketReader.cs b/Shared/PacketReader.cs new file mode 100644 index 0000000..9753108 --- /dev/null +++ b/Shared/PacketReader.cs @@ -0,0 +1,36 @@ +using System.IO; + +namespace BSO.Net +{ + /// + /// BinaryReader fuer Daten aus dem Netz (wie bei MSCO): jede Laengenangabe wird gegen die tatsaechlich vorhandenen + /// Bytes geprueft - eine gefaelschte Laenge darf nicht zu einer riesigen Speicheranforderung fuehren. + /// + public sealed class PacketReader : BinaryReader + { + public PacketReader(byte[] data) : base(new MemoryStream(data ?? new byte[0], false)) { } + + long Remaining => BaseStream.Length - BaseStream.Position; + + public override byte[] ReadBytes(int count) + { + if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count); + return base.ReadBytes(count); + } + + public override char[] ReadChars(int count) + { + if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count); + return base.ReadChars(count); + } + + public override string ReadString() + { + long start = BaseStream.Position; + int length = Read7BitEncodedInt(); + if (length < 0 || length > Remaining) throw new EndOfStreamException("Invalid string length " + length); + BaseStream.Position = start; + return base.ReadString(); + } + } +} diff --git a/Shared/Protocol.cs b/Shared/Protocol.cs new file mode 100644 index 0000000..f47b0c0 --- /dev/null +++ b/Shared/Protocol.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; + +namespace BSO.Net +{ + /// + /// Netzwerkprotokoll von Mod und Server - beide binden diese Dateien ein (Shared\*.cs), es gibt also nur eine + /// Fassung. Aufbau wie bei MSCO: [int Laenge][byte Typ][Daten], Daten mit BinaryWriter, Positionen in cm (Engine). + /// + public static class Protocol + { + /// Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab. + public const int Version = 1; + public const int DefaultPort = 7777; + } + + public enum MsgType : byte + { + Hello = 1, // Client -> Server: int Protokoll, string Mod-Version, string Name + Welcome = 2, // Server -> Client: int eigene ID, string Servername, int n, je [int ID, string Name] + Reject = 3, // Server -> Client: string Grund + PlayerState = 4, // Client -> Server: PlayerState; Server -> Client: int ID + PlayerState + PlayerJoined = 5, // Server -> Client: int ID, string Name + PlayerLeft = 6, // Server -> Client: int ID + Ping = 7, // long Zeitstempel (Stopwatch-Ticks des Senders) + Pong = 8, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen) + Info = 9, // Abfrage ohne Hello (spaeter Server-Browser, jetzt --probe) + InfoReply = 10 // string Servername, string Version, int Spieler, int max. Spieler + } + + public enum PlayerMode : byte + { + Menu = 0, // noch keine Szene / Ladebildschirm + OnFoot = 1, // Spielfigur (KeyControlledCharacter) + InBus = 2 // sitzt im eigenen Bus (CarVehicle) + } + + public struct Message + { + public MsgType Type; + public byte[] Payload; + + public BinaryReader Reader() => new PacketReader(Payload); + } + + /// Was jeder Spieler ~10x pro Sekunde von sich schickt. Drehung wie die Engine: Grad (Gier, Nick, Roll). + public struct PlayerState + { + public PlayerMode Mode; + public float X, Y, Z; + public float Yaw, Pitch, Roll; + public float Speed; // cm/s + + public void Write(BinaryWriter w) + { + w.Write((byte)Mode); + w.Write(X); w.Write(Y); w.Write(Z); + w.Write(Yaw); w.Write(Pitch); w.Write(Roll); + w.Write(Speed); + } + + public static PlayerState Read(BinaryReader r) => new PlayerState + { + Mode = (PlayerMode)r.ReadByte(), + X = r.ReadSingle(), Y = r.ReadSingle(), Z = r.ReadSingle(), + Yaw = r.ReadSingle(), Pitch = r.ReadSingle(), Roll = r.ReadSingle(), + Speed = r.ReadSingle() + }; + + public float DistanceTo(PlayerState o) + { + float dx = X - o.X, dy = Y - o.Y, dz = Z - o.Z; + return MathF.Sqrt(dx * dx + dy * dy + dz * dz); + } + } + + public static class Wire + { + public static byte[] Build(Action write) + { + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + write(w); + w.Flush(); + return ms.ToArray(); + } + } +} diff --git a/TestBot.cs b/TestBot.cs new file mode 100644 index 0000000..7993a7c --- /dev/null +++ b/TestBot.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using BSO.Net; + +namespace BSO.Server; + +/// +/// Fake-Mitspieler zum Alleine-Testen: faehrt als "Bus" Kreise (20 m Radius, ~30 km/h) um die Stelle, an der der erste +/// Spieler im Spiel ist. Entfernt sich der Spieler mehr als 300 m, zieht der Kreis zu ihm um. +/// +public class TestBot +{ + const float Radius = 2000; // cm + const float SpeedCm = 30 / 0.036f; // 30 km/h in cm/s + const float Follow = 30000; // cm + static readonly TimeSpan SendInterval = TimeSpan.FromMilliseconds(100); + + public int Id { get; } + public string Name => "Testbot"; + public PlayerState State { get; private set; } + public bool HasState { get; private set; } + + readonly Stopwatch clock = Stopwatch.StartNew(); + TimeSpan lastSend; + float centerX, centerY, angle; + bool hasCenter; + + public TestBot(int id) => Id = id; + + /// Bewegt den Bot weiter; true = neuer Zustand ist faellig (10x pro Sekunde). + public bool Tick(IEnumerable players) + { + TimeSpan now = clock.Elapsed; + if (now - lastSend < SendInterval) return false; + float dt = (float)(now - lastSend).TotalSeconds; + lastSend = now; + + PlayerState? target = players.Where(p => p.Mode != PlayerMode.Menu).Cast().FirstOrDefault(); + if (target == null) return false; + PlayerState t = target.Value; + float dx = t.X - centerX, dy = t.Y - centerY; + if (!hasCenter || dx * dx + dy * dy > Follow * Follow) + { + centerX = t.X; + centerY = t.Y; + hasCenter = true; + GameServer.Log($"Testbot faehrt jetzt Kreise um {centerX / 100:0} / {centerY / 100:0} m"); + } + + angle += SpeedCm / Radius * Math.Min(dt, 1); + State = new PlayerState + { + Mode = PlayerMode.InBus, + X = centerX + Radius * MathF.Cos(angle), + Y = centerY + Radius * MathF.Sin(angle), + Z = t.Z, + Yaw = (angle * 180 / MathF.PI + 90) % 360, // Fahrtrichtung = Tangente + Speed = SpeedCm + }; + HasState = true; + return true; + } +} diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000..b531e3d Binary files /dev/null and b/logo.ico differ diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..4a3273d --- /dev/null +++ b/release.ps1 @@ -0,0 +1,45 @@ +# Signiert ein Release und laedt es auf den BSO Master (wie release.ps1 bei MSCO). +# .\release.ps1 -Product mod|launcher|server -Version 0.2.0 -File dist\BSO-Mod.zip [-Changelog "..."] +# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1), BSO_RELEASE_TOKEN (= RELEASE_TOKEN des Masters), +# optional BSO_MASTER_URL (Standard https://bso.mbm.wtf). +# Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten oeffentlichen Schluessel. +param( + [Parameter(Mandatory)][ValidateSet("mod", "launcher", "server")][string]$Product, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$File, + [string]$Changelog = "" +) +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Security +Add-Type -AssemblyName System.Net.Http + +$master = ($env:BSO_MASTER_URL, "https://bso.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/') +$token = $env:BSO_RELEASE_TOKEN +if (-not $token) { Write-Host "BSO_RELEASE_TOKEN nicht gesetzt - nicht hochgeladen"; return } +$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi" +if (-not (Test-Path $keyFile)) { throw "Kein Release-Schluessel ($keyFile) - erst new-release-key.ps1 im BSO-Launcher-Repo" } + +$data = [IO.File]::ReadAllBytes((Resolve-Path $File)) +$sha = ([System.Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join "" +$private = [System.Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser) +$rsa = [System.Security.Cryptography.RSA]::Create() +$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private)) +$message = [Text.Encoding]::UTF8.GetBytes("BSO-RELEASE|$Product|$Version|$sha") +$signature = [Convert]::ToBase64String($rsa.SignData($message, [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)) + +$http = New-Object System.Net.Http.HttpClient +$http.Timeout = [TimeSpan]::FromMinutes(10) +$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token) +$form = New-Object System.Net.Http.MultipartFormDataContent +foreach ($pair in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) { + $form.Add((New-Object System.Net.Http.StringContent($pair[1])), "`"$($pair[0])`"") +} +$content = New-Object System.Net.Http.ByteArrayContent(, $data) +$form.Add($content, "`"file`"", "`"$(Split-Path $File -Leaf)`"") +$res = $http.PostAsync("$master/api/releases/upload", $form).Result +$body = $res.Content.ReadAsStringAsync().Result +if ($res.StatusCode -eq [Net.HttpStatusCode]::Conflict) { Write-Host "$Product $Version gibt es schon auf $master"; return } +if (-not $res.IsSuccessStatusCode) { throw "Hochladen fehlgeschlagen ($([int]$res.StatusCode)): $body" } +Write-Host "$Product $Version hochgeladen nach $master (sha256 $sha)"