Files
HokmPlay/server/src/Hokm.Server/Game/GameManager.cs
T
soroush.asadi e49df07c0f
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m47s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s
Prod hardening: one-game-per-player, selectable music, bargevasat.ir config
- 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>
2026-06-06 23:05:52 +03:30

178 lines
6.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (1218s) 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);
}
}