Files
HokmPlay/server/src/Hokm.Server/Social/SocialService.cs
T
soroush.asadi cb27a16dc1 feat: UNO-style table, social hub, cosmetics, speed mode, store IAB
Game table & play
- UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow,
  big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round
  confetti, match coin-rain.
- Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert;
  mirrored server-side in GameRoom.TurnMs.
- Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing.
- Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint.

Rewards / gifts
- Richer post-match modal (floating coins, XP bar), celebration overlay reveals
  the unlocked sticker pack, boosted daily rewards (client+server synced),
  themed 7-day daily with special day-7.

Social
- Public profile modal (identity, stats, achievement board) from leaderboard /
  friends / discover / end-of-game roster; rate-limited add-friend (10/hour).
- Social hub: Friends / Discover (player search + suggestions) / Messages inbox.
- Profile gender (shown in finder/profile) + social links with public/friends/
  hidden visibility, enforced server-side.

Cosmetics
- Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/
  rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts),
  consistent on table/shop/profile; +Peacock/Rose-Gold backs.
- Purchasable titles (shop Titles section); title shown under the seat on the
  table and in discover/public profile.
- 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods).
- Persistent level+XP bar on Home and every inner screen.

Payments
- Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh.
- Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture,
  Myket native-bridge contract, server-side IabService.Verify for both stores,
  config-driven via Iab__* env. POST /api/coins/iab/verify (JWT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:39:24 +03:30

288 lines
12 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
using Hokm.Server.Data;
using Hokm.Server.Game;
using Hokm.Server.Hubs;
using Hokm.Server.Profiles;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace Hokm.Server.Social;
public class SocialService
{
private readonly AppDbContext _db;
private readonly GameManager _mgr;
private readonly IHubContext<GameHub> _hub;
/// <summary>Max outgoing friend requests allowed per user within a rolling hour.</summary>
public const int FriendReqLimit = 10;
private static readonly TimeSpan FriendReqWindow = TimeSpan.FromHours(1);
// Process-wide log of each user's recent outgoing-request timestamps (resets on restart).
private static readonly ConcurrentDictionary<string, List<DateTime>> _reqLog = new();
public SocialService(AppDbContext db, GameManager mgr, IHubContext<GameHub> hub)
{
_db = db;
_mgr = mgr;
_hub = hub;
}
/// <summary>
/// Records an outgoing friend-request attempt against the rolling-hour cap.
/// Returns false (with the minutes until a slot frees) when over the limit.
/// </summary>
private static bool TryRecordRequest(string uid, out int retryMins)
{
retryMins = 0;
var now = DateTime.UtcNow;
var list = _reqLog.GetOrAdd(uid, _ => new List<DateTime>());
lock (list)
{
list.RemoveAll(t => now - t >= FriendReqWindow);
if (list.Count >= FriendReqLimit)
{
retryMins = Math.Max(1, (int)Math.Ceiling((FriendReqWindow - (now - list[0])).TotalMinutes));
return false;
}
list.Add(now);
return true;
}
}
private async Task<FriendDto> FriendDtoFor(string userId)
{
var row = await _db.Profiles.FindAsync(userId);
var p = row != null ? JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default) : null;
return new FriendDto
{
Id = userId,
Username = p?.Username ?? userId,
DisplayName = p?.DisplayName ?? userId,
Avatar = p?.Avatar ?? "a-fox",
Level = p?.Level ?? 1,
Rating = p?.Rating ?? 1000,
Status = _mgr.IsOnline(userId) ? "online" : "offline",
};
}
/* ----------------------------- friends ----------------------------- */
public async Task<List<FriendDto>> ListFriends(string uid)
{
var ids = await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync();
var list = new List<FriendDto>();
foreach (var id in ids) list.Add(await FriendDtoFor(id));
return list;
}
public async Task<List<FriendRequestDto>> ListRequests(string uid)
{
var reqs = await _db.FriendRequests.Where(r => r.ToUserId == uid).ToListAsync();
var list = new List<FriendRequestDto>();
foreach (var r in reqs)
list.Add(new FriendRequestDto { Id = r.Id.ToString(), From = await FriendDtoFor(r.FromUserId), CreatedAt = new DateTimeOffset(r.CreatedAt).ToUnixTimeMilliseconds() });
return list;
}
public async Task<(bool ok, string messageFa, string messageEn)> AddFriend(string uid, string query)
{
var digits = new string(query.Where(char.IsDigit).ToArray());
var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.Trim());
return await AddFriendById(uid, targetId);
}
/// <summary>Send a friend request to a concrete user id (rate-limited to 10/hour).</summary>
public async Task<(bool ok, string messageFa, string messageEn)> AddFriendById(string uid, string targetId)
{
targetId = targetId.Trim();
var target = await _db.Profiles.FindAsync(targetId);
if (target == null || targetId == uid)
return (false, "کاربر پیدا نشد", "User not found");
if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId))
return (false, "از قبل دوست هستید", "Already friends");
// Already pending → idempotent success, doesn't consume the hourly quota.
if (await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
if (!TryRecordRequest(uid, out var mins))
return (false,
$"در هر ساعت حداکثر {FriendReqLimit} درخواست دوستی می‌توانید بفرستید. {mins} دقیقه دیگر تلاش کنید.",
$"You can send at most {FriendReqLimit} friend requests per hour. Try again in {mins} min.");
_db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
await _db.SaveChangesAsync();
await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
}
/* --------------------------- discovery ----------------------------- */
private PlayerSummaryDto ToSummary(ProfileDto p, HashSet<string> friendIds, HashSet<string> sentIds) => new()
{
Id = p.Id,
DisplayName = p.DisplayName,
Avatar = p.Avatar,
AvatarImage = p.AvatarImage,
Level = p.Level,
Rating = p.Rating,
Status = _mgr.IsOnline(p.Id) ? "online" : "offline",
Gender = p.Gender ?? "",
Title = p.Title,
IsFriend = friendIds.Contains(p.Id),
RequestSent = sentIds.Contains(p.Id),
};
/// <summary>Search players by display name (case-insensitive contains).</summary>
public async Task<List<PlayerSummaryDto>> SearchPlayers(string uid, string query)
{
query = (query ?? "").Trim();
if (query.Length == 0) return new();
var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet();
var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet();
var rows = await _db.Profiles.Where(p => p.Id != uid).ToListAsync();
var list = new List<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p?.DisplayName == null) continue;
if (!p.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
list.Add(ToSummary(p, friendIds, sentIds));
if (list.Count >= 20) break;
}
return list;
}
/// <summary>Suggested players to befriend (online-first, excludes existing friends).</summary>
public async Task<List<PlayerSummaryDto>> Suggested(string uid)
{
var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet();
var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet();
var rows = await _db.Profiles.Where(p => p.Id != uid).Take(80).ToListAsync();
var list = new List<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p == null || friendIds.Contains(p.Id)) continue;
list.Add(ToSummary(p, friendIds, sentIds));
}
// Online players first, then by rating.
return list
.OrderByDescending(x => x.Status == "online")
.ThenByDescending(x => x.Rating)
.Take(12)
.ToList();
}
/// <summary>Another player's public profile + achievement board (no private fields).</summary>
public async Task<PublicProfileDto?> GetPublicProfile(string uid, string targetId)
{
targetId = targetId.Trim();
var row = await _db.Profiles.FindAsync(targetId);
if (row == null) return null;
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p == null) return null;
var isFriend = await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId);
var requestSent = await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId);
var isYou = targetId == uid;
// Social links honor the owner's privacy: public → everyone, friends → only
// friends (and the owner), hidden → nobody.
var vis = string.IsNullOrEmpty(p.SocialsVisibility) ? "public" : p.SocialsVisibility;
var canSeeSocials = isYou || vis == "public" || (vis == "friends" && isFriend);
return new PublicProfileDto
{
Id = p.Id,
DisplayName = p.DisplayName,
Avatar = p.Avatar,
AvatarImage = p.AvatarImage,
Plan = p.Plan,
Title = p.Title,
Level = p.Level,
Rating = p.Rating,
Stats = p.Stats,
Achievements = p.Achievements,
Unlocked = p.Unlocked,
CreatedAt = p.CreatedAt,
Gender = p.Gender ?? "",
Socials = canSeeSocials ? p.Socials : null,
IsFriend = isFriend,
IsYou = isYou,
RequestSent = requestSent,
};
}
public async Task Accept(string uid, long requestId)
{
var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
if (req == null) return;
_db.Friends.Add(new FriendEdgeRow { UserId = uid, FriendId = req.FromUserId });
_db.Friends.Add(new FriendEdgeRow { UserId = req.FromUserId, FriendId = uid });
_db.FriendRequests.Remove(req);
await _db.SaveChangesAsync();
await _hub.Clients.User(req.FromUserId).SendAsync("social", "friend-added");
}
public async Task Decline(string uid, long requestId)
{
var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
if (req != null) { _db.FriendRequests.Remove(req); await _db.SaveChangesAsync(); }
}
public async Task Remove(string uid, string friendId)
{
var edges = await _db.Friends.Where(f =>
(f.UserId == uid && f.FriendId == friendId) || (f.UserId == friendId && f.FriendId == uid)).ToListAsync();
_db.Friends.RemoveRange(edges);
await _db.SaveChangesAsync();
await _hub.Clients.User(friendId).SendAsync("social", "friend-removed");
}
/* ------------------------------- chat ------------------------------ */
public async Task<List<ConversationDto>> Conversations(string uid)
{
var msgs = await _db.Messages.Where(m => m.UserId == uid || m.PeerId == uid).ToListAsync();
var byPartner = msgs.GroupBy(m => m.UserId == uid ? m.PeerId : m.UserId);
var convs = new List<ConversationDto>();
foreach (var g in byPartner)
{
var last = g.OrderByDescending(m => m.CreatedAt).First();
convs.Add(new ConversationDto
{
Friend = await FriendDtoFor(g.Key),
LastMessage = ToDto(last, uid),
Unread = g.Count(m => m.PeerId == uid && !m.ReadByPeer),
});
}
return convs.OrderByDescending(c => c.LastMessage?.Ts ?? 0).ToList();
}
public async Task<List<ChatMessageDto>> Messages(string uid, string peerId)
{
var msgs = await _db.Messages
.Where(m => (m.UserId == uid && m.PeerId == peerId) || (m.UserId == peerId && m.PeerId == uid))
.OrderBy(m => m.CreatedAt).ToListAsync();
var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList();
if (unread.Count > 0) { unread.ForEach(m => m.ReadByPeer = true); await _db.SaveChangesAsync(); }
return msgs.Select(m => ToDto(m, uid)).ToList();
}
public async Task<ChatMessageDto> Send(string uid, string peerId, string text)
{
var m = new MessageRow { UserId = uid, PeerId = peerId, Text = text.Trim(), CreatedAt = DateTime.UtcNow };
_db.Messages.Add(m);
await _db.SaveChangesAsync();
await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid });
return ToDto(m, uid);
}
private static ChatMessageDto ToDto(MessageRow m, string uid) => new()
{
Id = m.Id.ToString(),
FromMe = m.UserId == uid,
Text = m.Text,
Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(),
};
}