- Heartbeat an die Website, Abmelden beim Beenden (--private, --public-address, --description, --master) - Spiel-Tickets pruefen (Signatur der Website), Name aus dem Konto, --no-auth fuer LAN - IPv4 und IPv6, Protokoll 2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
11 KiB
C#
272 lines
11 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.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<int, Client> clients = new();
|
|
int nextId = 1;
|
|
|
|
public string Name { get; }
|
|
public int MaxPlayers { get; }
|
|
public TestBot Bot { get; private set; }
|
|
/// <summary>Fuer den Heartbeat (anderer Thread) - wird in Tick aktualisiert.</summary>
|
|
public int PlayerCount => playerCount;
|
|
volatile int playerCount;
|
|
|
|
/// <summary>Spiel-Tickets pruefen (Konto der BSO-Website)? Aus mit --no-auth (LAN, Tests).</summary>
|
|
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<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();
|
|
}
|
|
}
|