cb27a16dc1
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>
288 lines
12 KiB
C#
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(),
|
|
};
|
|
}
|