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.3.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; public string AccountId; // aus dem Spiel-Ticket (null mit --no-auth) } readonly TcpListener listener; readonly Dictionary clients = new(); int nextId = 1; public string Name { get; } public int MaxPlayers { get; } public TestBot Bot { get; private set; } /// Fuer den Heartbeat (anderer Thread) - wird in Tick aktualisiert. public int PlayerCount => playerCount; volatile int playerCount; /// Spiel-Tickets pruefen (Konto der BSO-Website)? Aus mit --no-auth (LAN, Tests). public bool RequireAccount { get; set; } = true; public MasterClient Master { get; set; } public GameServer(int port, string name, int maxPlayers) { Name = name; MaxPlayers = maxPlayers; // IPv4 und IPv6 (die Website prueft die Erreichbarkeit ueber die Adresse, von der der Heartbeat kam - das kann IPv6 sein) listener = new TcpListener(IPAddress.IPv6Any, port); listener.Server.DualMode = true; 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); } playerCount = Players().Count(); 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; } // Ab Protokoll 2: Spiel-Ticket des Launchers - der Name kommt dann aus dem Konto, nicht vom Spieler string ticketText = r.ReadString(); if (RequireAccount) { if (Master?.TicketKey == null) { Reject(c, "Der Server kann gerade keine BSO-Konten pruefen (Website nicht erreichbar)"); return; } Ticket ticket = Ticket.Verify(ticketText, Master.TicketKey, out string ticketError); if (ticket == null) { Reject(c, ticketError); return; } if (Players().Any(p => p.AccountId == ticket.AccountId)) { Reject(c, $"Das Konto {ticket.Username} ist schon auf diesem Server"); return; } c.AccountId = ticket.AccountId; name = Clean(ticket.Username, c.Id); } 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(); } }