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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Hokm.Server.Data;
|
||||
using Hokm.Server.Game;
|
||||
@@ -14,6 +15,12 @@ public class SocialService
|
||||
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;
|
||||
@@ -21,6 +28,28 @@ public class SocialService
|
||||
_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);
|
||||
@@ -60,21 +89,129 @@ public class SocialService
|
||||
{
|
||||
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");
|
||||
if (!await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
|
||||
{
|
||||
_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));
|
||||
}
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user