Files
Server/LobbyManager.cs
2026-04-26 12:44:06 +02:00

301 lines
10 KiB
C#

namespace GeoSus.Server;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
// Správa lobby - vytváření, join codes, vyhledávání
public class LobbyManager
{
private readonly ServerConfig _config;
private readonly ILogger<LobbyManager> _logger;
private readonly StatsDb _statsDb;
private readonly Persistence _persistence;
private readonly OverpassService _overpassService;
private readonly ConcurrentDictionary<string, LobbyActor> _lobbies = new();
private readonly ConcurrentDictionary<string, string> _joinCodes = new(); // code -> lobbyId
private readonly ConcurrentDictionary<string, DateTime> _codeExpiry = new();
private readonly ConcurrentDictionary<string, RateLimiter> _joinRateLimiters = new();
// Znaky pro join code - bez podobných (0/O, 1/I/L)
private const string JoinCodeChars = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
public LobbyManager(ServerConfig config, ILogger<LobbyManager> logger, StatsDb statsDb, Persistence persistence, OverpassService overpassService)
{
_config = config;
_logger = logger;
_statsDb = statsDb;
_persistence = persistence;
_overpassService = overpassService;
}
public async Task<(LobbyActor? Lobby, string? JoinCode, string? Error)> CreateLobbyAsync(
string ownerId, string ownerName, CreateLobby request)
{
var lobbyId = Guid.NewGuid().ToString("N")[..16];
var joinCode = GenerateJoinCode();
var playAreaCenter = request.PlayAreaCenter ?? new Position(50.0, 14.0); // Default Praha
var playAreaRadius = request.PlayAreaRadius > 0 ? request.PlayAreaRadius : 500;
// NOTE: Map data is now fetched when game starts, not on lobby creation
// This allows all clients to see a loading screen while data is fetched
var settings = new LobbySettings
{
LobbyId = lobbyId,
JoinCode = joinCode,
Password = request.Password,
PlayAreaCenter = playAreaCenter,
PlayAreaRadius = playAreaRadius,
ImpostorCount = request.ImpostorCount > 0 ? request.ImpostorCount : _config.DefaultImpostorCount,
TaskCount = request.TaskCount > 0 ? request.TaskCount : _config.DefaultTaskCount,
TiePolicy = _config.DefaultTiePolicy,
EmergencyMeetingCooldownMs = _config.EmergencyMeetingCooldownMs,
MaxEmergencyMeetingsPerPlayer = _config.MaxEmergencyMeetingsPerPlayer,
AllowJoinInProgress = false,
MapData = null,
MapDataPayload = null,
OverpassEnabled = _config.OverpassEnabled
};
var lobbyLogger = _logger.CreateLogger<LobbyActor>();
var lobby = new LobbyActor(lobbyId, settings, _config, lobbyLogger, _statsDb, _persistence, _overpassService);
if (!_lobbies.TryAdd(lobbyId, lobby))
{
return (null, null, "Nepodařilo se vytvořit lobby");
}
_joinCodes[joinCode] = lobbyId;
_codeExpiry[joinCode] = DateTime.UtcNow.AddMilliseconds(_config.JoinCodeTtlMs);
// Přidáme vlastníka
await lobby.AddPlayerAsync(ownerId, ownerName, isOwner: true);
_logger.LogInformation("Lobby {LobbyId} vytvořeno, join code: {JoinCode}", lobbyId, joinCode);
return (lobby, joinCode, null);
}
public async Task<(LobbyActor? Lobby, string? Error)> JoinLobbyAsync(
string clientIp, string clientUuid, string displayName, string joinCode, string? password)
{
// Rate limiting per IP
var limiter = _joinRateLimiters.GetOrAdd(clientIp, _ => new RateLimiter(_config.JoinRateLimitPerMinute, TimeSpan.FromMinutes(1)));
if (!limiter.TryAcquire())
{
return (null, "Příliš mnoho pokusů o připojení. Počkej chvíli.");
}
// Najdeme lobby podle join code
if (!_joinCodes.TryGetValue(joinCode.ToUpperInvariant(), out var lobbyId))
{
return (null, "Neplatný join code");
}
// Kontrola expirace
if (_codeExpiry.TryGetValue(joinCode.ToUpperInvariant(), out var expiry) && expiry < DateTime.UtcNow)
{
_joinCodes.TryRemove(joinCode.ToUpperInvariant(), out _);
_codeExpiry.TryRemove(joinCode.ToUpperInvariant(), out _);
return (null, "Join code vypršel");
}
if (!_lobbies.TryGetValue(lobbyId, out var lobby))
{
return (null, "Lobby již neexistuje");
}
// Kontrola hesla
if (!string.IsNullOrEmpty(lobby.Settings.Password) && lobby.Settings.Password != password)
{
return (null, "Špatné heslo");
}
// Kontrola fáze
if (lobby.Phase != GamePhase.Lobby && !lobby.Settings.AllowJoinInProgress)
{
return (null, "Hra již probíhá");
}
// Kontrola počtu hráčů
if (lobby.PlayerCount >= _config.MaxPlayersPerLobby)
{
return (null, "Lobby je plné");
}
await lobby.AddPlayerAsync(clientUuid, displayName, isOwner: false);
_logger.LogInformation("Hráč {ClientUuid} se připojil do lobby {LobbyId}", clientUuid, lobbyId);
return (lobby, null);
}
public LobbyActor? GetLobby(string lobbyId)
{
_lobbies.TryGetValue(lobbyId, out var lobby);
return lobby;
}
public LobbyActor? GetLobbyByJoinCode(string joinCode)
{
if (_joinCodes.TryGetValue(joinCode.ToUpperInvariant(), out var lobbyId))
{
return GetLobby(lobbyId);
}
return null;
}
public IEnumerable<LobbyActor> GetAllLobbies()
{
return _lobbies.Values;
}
public void RemoveLobby(string lobbyId)
{
if (_lobbies.TryRemove(lobbyId, out var lobby))
{
// Odstraníme join code
var joinCode = lobby.Settings.JoinCode;
_joinCodes.TryRemove(joinCode, out _);
_codeExpiry.TryRemove(joinCode, out _);
lobby.Dispose();
_logger.LogInformation("Lobby {LobbyId} odstraněno", lobbyId);
}
}
// Čištění idle lobby
public async Task CleanupIdleLobbiesAsync()
{
var now = DateTime.UtcNow;
var idleThreshold = TimeSpan.FromMilliseconds(_config.IdleLobbyTtlMs);
foreach (var (lobbyId, lobby) in _lobbies)
{
if (now - lobby.LastActivity > idleThreshold)
{
_logger.LogInformation("Ruším idle lobby {LobbyId}", lobbyId);
await lobby.ArchiveAndCloseAsync();
RemoveLobby(lobbyId);
}
}
// Čištění expirovaných join codes
foreach (var (code, expiry) in _codeExpiry)
{
if (expiry < now)
{
_joinCodes.TryRemove(code, out _);
_codeExpiry.TryRemove(code, out _);
}
}
}
private string GenerateJoinCode()
{
Span<byte> randomBytes = stackalloc byte[6];
RandomNumberGenerator.Fill(randomBytes);
var sb = new StringBuilder(6);
foreach (var b in randomBytes)
{
sb.Append(JoinCodeChars[b % JoinCodeChars.Length]);
}
var code = sb.ToString();
// Pokud kód existuje, generujeme znovu
if (_joinCodes.ContainsKey(code))
{
return GenerateJoinCode();
}
return code;
}
public int LobbyCount => _lobbies.Count;
public int TotalPlayerCount => _lobbies.Values.Sum(l => l.PlayerCount);
}
public class LobbySettings
{
public required string LobbyId { get; set; }
public required string JoinCode { get; set; }
public string? Password { get; set; }
public Position PlayAreaCenter { get; set; }
public double PlayAreaRadius { get; set; }
public int ImpostorCount { get; set; }
public int TaskCount { get; set; }
public TiePolicy TiePolicy { get; set; }
public int EmergencyMeetingCooldownMs { get; set; }
public int MaxEmergencyMeetingsPerPlayer { get; set; }
public bool AllowJoinInProgress { get; set; }
public Position EmergencyMeetingLocation { get; set; } // Střed play area jako default
/// <summary>Full map data from Overpass (server-side only, for task placement)</summary>
public MapData? MapData { get; set; }
/// <summary>Compact map data payload for clients</summary>
public MapDataPayload? MapDataPayload { get; set; }
/// <summary>Whether Overpass API was enabled for this lobby</summary>
public bool OverpassEnabled { get; set; }
}
// Jednoduchý rate limiter
public class RateLimiter
{
private readonly int _maxRequests;
private readonly TimeSpan _window;
private readonly Queue<DateTime> _requests = new();
private readonly object _lock = new();
public RateLimiter(int maxRequests, TimeSpan window)
{
_maxRequests = maxRequests;
_window = window;
}
public bool TryAcquire()
{
lock (_lock)
{
var now = DateTime.UtcNow;
var cutoff = now - _window;
// Odstraníme staré požadavky
while (_requests.Count > 0 && _requests.Peek() < cutoff)
{
_requests.Dequeue();
}
if (_requests.Count >= _maxRequests)
{
return false;
}
_requests.Enqueue(now);
return true;
}
}
}
// Extension pro vytvoření sub-loggeru
public static class LoggerExtensions
{
public static ILogger<T> CreateLogger<T>(this ILogger logger)
{
if (logger is ILoggerFactory factory)
{
return factory.CreateLogger<T>();
}
// Fallback - vrátíme prázdný logger
return new LoggerFactory().CreateLogger<T>();
}
}