301 lines
10 KiB
C#
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>();
|
|
}
|
|
}
|