Files
BSO-Server/GameServer.cs
T
MrMahoandClaude Opus 5.5 cda31cd070 Erste Version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:26:24 +01:00

238 lines
8.9 KiB
C#

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();
}
}