@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
dist/
|
||||||
|
publish/
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>BSO.Server</RootNamespace>
|
||||||
|
<AssemblyName>BSO.Server</AssemblyName>
|
||||||
|
<Version>0.2.0</Version>
|
||||||
|
<Product>BSO Dedicated Server</Product>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<!-- Symbol der .exe (branding\make-assets.ps1) -->
|
||||||
|
<ApplicationIcon>logo.ico</ApplicationIcon>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="publish/**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using BSO.Net;
|
||||||
|
|
||||||
|
namespace BSO.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<int, Client> 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<Client> Players() => clients.Values.Where(c => c.Welcomed && !c.Conn.IsClosed);
|
||||||
|
|
||||||
|
/// <summary>Alle anderen Spieler inkl. Testbot: (ID, Name, hat Zustand, Zustand).</summary>
|
||||||
|
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 <id> [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<string>();
|
||||||
|
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 <id> [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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -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 <id>, stop");
|
||||||
|
|
||||||
|
var commands = new ConcurrentQueue<string>();
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Verbindet sich wie die Mod (Info, dann Hello als "Probe") und zeigt 5 s lang alles, was ankommt.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 <id> [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 "..."
|
||||||
|
```
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace BSO.Net
|
||||||
|
{
|
||||||
|
/// <summary>Setzt aus einem Byte-Strom Nachrichten zusammen: [int Laenge][byte Typ][Payload].</summary>
|
||||||
|
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<Message> 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<byte>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte[]> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sende-Thread: wartet auf Pakete und schreibt sie (gebuendelt) in die Verbindung.</summary>
|
||||||
|
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<Message> polled = new(); // wiederverwendet: der Aufrufer geht sie sofort durch
|
||||||
|
public List<Message> 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace BSO.Net
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace BSO.Net
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static class Protocol
|
||||||
|
{
|
||||||
|
/// <summary>Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was jeder Spieler ~10x pro Sekunde von sich schickt. Drehung wie die Engine: Grad (Gier, Nick, Roll).</summary>
|
||||||
|
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<BinaryWriter> write)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var w = new BinaryWriter(ms);
|
||||||
|
write(w);
|
||||||
|
w.Flush();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using BSO.Net;
|
||||||
|
|
||||||
|
namespace BSO.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Bewegt den Bot weiter; true = neuer Zustand ist faellig (10x pro Sekunde).</summary>
|
||||||
|
public bool Tick(IEnumerable<PlayerState> 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<PlayerState?>().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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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)"
|
||||||
Reference in New Issue
Block a user