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