e49df07c0f
- One running game per player: server rejects a 2nd matchmake while in a live room (re-syncs the existing game); client guards Home vs-computer + Lobby random/create — resumes the running match + notifies instead of starting another (game-store hasActiveMatch()). - Background music is now selectable: santoor (سنتی, calm Persian loop) and playful (bouncy UNO-like) — sound.ts TRACKS + setMusicTrack (persisted), sound-store musicTrack, picker in Profile → Audio. i18n added. - Production config for bargevasat.ir (prepare-only; no live deploy): appsettings.Production.example (CORS + ZarinPal + IAB to the domain), docker-compose.caddy.yml + Caddyfile (auto-HTTPS reverse proxy bargevasat.ir→web, api.bargevasat.ir→server), ENV_FILE PRODUCTION block, PRODUCTION.md go-live + Cafe Bazaar publish/IAB checklist. Fixed IAB package name to match Capacitor appId (com.bargevasat.app). Verified: tsc + next build + dotnet build all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
6.6 KiB
C#
178 lines
6.6 KiB
C#
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";
|
||
}
|
||
|
||
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
||
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<GameHub> _hub;
|
||
private readonly IServiceScopeFactory _scopes;
|
||
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();
|
||
|
||
public GameManager(IHubContext<GameHub> 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<Player> { 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<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),
|
||
};
|
||
}
|
||
}
|
||
|
||
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<string, int> _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);
|
||
}
|
||
}
|