2026-06-04 12:42:15 +03:30
|
|
|
using System.Collections.Concurrent;
|
|
|
|
|
using Hokm.Server.Hubs;
|
|
|
|
|
using Microsoft.AspNetCore.SignalR;
|
2026-06-04 17:32:47 +03:30
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
2026-06-04 12:42:15 +03:30
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
|
|
|
|
public sealed class GameManager
|
|
|
|
|
{
|
2026-06-04 22:47:36 +03:30
|
|
|
// Real players get priority: wait this long for humans before bots fill in.
|
|
|
|
|
private const int QueueWaitMs = 9000;
|
2026-06-04 12:42:15 +03:30
|
|
|
|
|
|
|
|
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<GameHub> _hub;
|
2026-06-04 17:32:47 +03:30
|
|
|
private readonly IServiceScopeFactory _scopes;
|
2026-06-04 12:42:15 +03:30
|
|
|
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
|
|
|
|
|
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
|
|
|
|
|
private readonly object _mmLock = new();
|
|
|
|
|
private readonly List<(Player player, Timer timer)> _waiting = new();
|
|
|
|
|
private readonly Random _rng = new();
|
|
|
|
|
|
2026-06-04 17:32:47 +03:30
|
|
|
public GameManager(IHubContext<GameHub> hub, IServiceScopeFactory scopes)
|
|
|
|
|
{
|
|
|
|
|
_hub = hub;
|
|
|
|
|
_scopes = scopes;
|
|
|
|
|
}
|
2026-06-04 12:42:15 +03:30
|
|
|
|
|
|
|
|
/* ----------------------------- matchmaking ------------------------- */
|
|
|
|
|
|
|
|
|
|
public void StartMatchmaking(Player p)
|
|
|
|
|
{
|
|
|
|
|
// Pro players skip the queue entirely.
|
|
|
|
|
if (p.Plan == "pro")
|
|
|
|
|
{
|
|
|
|
|
StartMatch(new List<Player> { p });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lock (_mmLock)
|
|
|
|
|
{
|
|
|
|
|
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
|
|
|
|
|
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, 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<Player> 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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 17:32:47 +03:30
|
|
|
var room = new GameRoom(_hub, _scopes, seats, ranked: true, stake: 100, targetScore: 7);
|
2026-06-04 12:42:15 +03:30
|
|
|
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);
|
2026-06-04 22:47:36 +03:30
|
|
|
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);
|
2026-06-04 12:42:15 +03:30
|
|
|
|
2026-06-04 18:26:22 +03:30
|
|
|
private readonly ConcurrentDictionary<string, int> _onlineUsers = new();
|
|
|
|
|
public int OnlineCount => _onlineUsers.Count;
|
|
|
|
|
public bool IsOnline(string userId) => _onlineUsers.ContainsKey(userId);
|
2026-06-04 13:20:51 +03:30
|
|
|
|
|
|
|
|
public void OnConnected(string userId)
|
|
|
|
|
{
|
2026-06-04 18:26:22 +03:30
|
|
|
_onlineUsers.AddOrUpdate(userId, 1, (_, n) => n + 1);
|
2026-06-04 13:20:51 +03:30
|
|
|
RoomOf(userId)?.SetConnected(userId, true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 12:42:15 +03:30
|
|
|
public void OnDisconnected(string userId)
|
|
|
|
|
{
|
2026-06-04 18:26:22 +03:30
|
|
|
if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0)
|
|
|
|
|
_onlineUsers.TryRemove(userId, out _);
|
2026-06-04 12:42:15 +03:30
|
|
|
CancelMatchmaking(userId);
|
|
|
|
|
RoomOf(userId)?.SetConnected(userId, false);
|
|
|
|
|
}
|
|
|
|
|
}
|