using System.Collections.Concurrent; using Hokm.Server.Hubs; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; namespace Hokm.Server.Game; public sealed class Player { public required string UserId { get; init; } public string Name { get; init; } = ""; public string Avatar { get; init; } = "a-fox"; public int Level { get; init; } public string Plan { get; init; } = "free"; } /// In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.) public sealed class GameManager { // Real players get priority: wait ~15s for humans before bots fill in. The // exact wait is randomized per ticket (12–18s) so the queue doesn't feel // robotically identical every time. private const int QueueWaitMinMs = 12000; private const int QueueWaitMaxMs = 18000; private int NextQueueWaitMs() => _rng.Next(QueueWaitMinMs, QueueWaitMaxMs + 1); private static readonly string[] BotNames = { "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" }; private static readonly string[] Avatars = { "a-fox", "a-lion", "a-owl", "a-tiger", "a-panda", "a-eagle", "a-wolf", "a-cat" }; private readonly IHubContext _hub; private readonly IServiceScopeFactory _scopes; private readonly ConcurrentDictionary _rooms = new(); private readonly ConcurrentDictionary _userRoom = new(); // userId -> roomId private readonly object _mmLock = new(); private readonly List<(Player player, Timer timer)> _waiting = new(); private readonly Random _rng = new(); public GameManager(IHubContext hub, IServiceScopeFactory scopes) { _hub = hub; _scopes = scopes; } /* ----------------------------- matchmaking ------------------------- */ public void StartMatchmaking(Player p) { // One running game per player: if already in a live match, re-sync them to // it (re-broadcasts current state) instead of starting a second game. if (RoomOf(p.UserId) is { } existing) { existing.SetConnected(p.UserId, true); return; } // Pro players skip the queue entirely. if (p.Plan == "pro") { StartMatch(new List { p }); return; } lock (_mmLock) { if (_waiting.Any(w => w.player.UserId == p.UserId)) return; var timer = new Timer(_ => FlushTicket(p.UserId), null, NextQueueWaitMs(), Timeout.Infinite); _waiting.Add((p, timer)); _ = _hub.Clients.User(p.UserId).SendAsync("matchmaking", new MatchmakingStateDto("searching", _waiting.Count, null)); if (_waiting.Count >= 4) FormGroupLocked(4); } } public void CancelMatchmaking(string userId) { lock (_mmLock) { var idx = _waiting.FindIndex(w => w.player.UserId == userId); if (idx >= 0) { _waiting[idx].timer.Dispose(); _waiting.RemoveAt(idx); } } } private void FlushTicket(string userId) { lock (_mmLock) { if (!_waiting.Any(w => w.player.UserId == userId)) return; FormGroupLocked(_waiting.Count); // start with whoever is waiting; bots fill the rest } } private void FormGroupLocked(int count) { var take = _waiting.Take(Math.Min(count, 4)).ToList(); foreach (var w in take) w.timer.Dispose(); _waiting.RemoveAll(w => take.Any(t => t.player.UserId == w.player.UserId)); StartMatch(take.Select(t => t.player).ToList()); } /* ------------------------------- rooms ----------------------------- */ private void StartMatch(List humans) { var seats = new SeatSlot[4]; for (int i = 0; i < 4; i++) { if (i < humans.Count) { var h = humans[i]; seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, Level = h.Level }; } else { seats[i] = new SeatSlot { Seat = i, IsBot = true, Name = BotNames[_rng.Next(BotNames.Length)], Avatar = Avatars[_rng.Next(Avatars.Length)], Level = _rng.Next(1, 50), }; } } var room = new GameRoom(_hub, _scopes, seats, ranked: true, stake: 100, targetScore: 7); room.OnFinished = FinishRoom; _rooms[room.Id] = room; foreach (var h in humans) _userRoom[h.UserId] = room.Id; foreach (var h in humans) _ = _hub.Clients.User(h.UserId).SendAsync("matchFound", new { roomId = room.Id, seat = room.SeatOf(h.UserId) }); room.Start(); } private void FinishRoom(GameRoom room) { foreach (var uid in room.UserIds) _userRoom.TryRemove(uid, out _); // keep briefly for any late reads, then dispose _ = Task.Delay(15000).ContinueWith(_ => { if (_rooms.TryRemove(room.Id, out var r)) r.Dispose(); }); } private GameRoom? RoomOf(string userId) => _userRoom.TryGetValue(userId, out var id) && _rooms.TryGetValue(id, out var r) ? r : null; /* --------------------------- player actions ------------------------ */ public void PlayCard(string userId, string cardId) => RoomOf(userId)?.HumanPlay(userId, cardId); public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit); public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction); public void RequestForfeit(string userId) => RoomOf(userId)?.RequestForfeit(userId); public void ConfirmForfeit(string userId) => RoomOf(userId)?.ConfirmForfeit(userId); public void DeclineForfeit(string userId) => RoomOf(userId)?.DeclineForfeit(userId); private readonly ConcurrentDictionary _onlineUsers = new(); public int OnlineCount => _onlineUsers.Count; public bool IsOnline(string userId) => _onlineUsers.ContainsKey(userId); public void OnConnected(string userId) { _onlineUsers.AddOrUpdate(userId, 1, (_, n) => n + 1); RoomOf(userId)?.SetConnected(userId, true); } public void OnDisconnected(string userId) { if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0) _onlineUsers.TryRemove(userId, out _); CancelMatchmaking(userId); RoomOf(userId)?.SetConnected(userId, false); } }