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:
@@ -17,8 +17,12 @@ public sealed class Player
|
||||
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
||||
public sealed class GameManager
|
||||
{
|
||||
// Real players get priority: wait this long for humans before bots fill in.
|
||||
private const int QueueWaitMs = 9000;
|
||||
// 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 =
|
||||
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
|
||||
@@ -53,7 +57,7 @@ public sealed class GameManager
|
||||
lock (_mmLock)
|
||||
{
|
||||
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
|
||||
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
|
||||
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));
|
||||
|
||||
@@ -28,7 +28,10 @@ public sealed class GameRoom : IDisposable
|
||||
private const int AiPlayMs = 800;
|
||||
private const int TrickPauseMs = 1100;
|
||||
private const int RoundPauseMs = 2500;
|
||||
public const int TurnMs = 20000;
|
||||
// Higher leagues (bigger stake) give LESS time to act — players think faster.
|
||||
// Starter/free → 15s, Pro (≥500) → 10s, Expert (≥1000) → 7s. Mirrors the
|
||||
// client's turnMsForStake() so the turn clock matches in either mode.
|
||||
private int TurnMs => Stake >= 1000 ? 7000 : Stake >= 500 ? 10000 : 15000;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly IHubContext<GameHub> _hub;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Hokm.Server.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Config for store in-app billing verification. Fill these from the Cafe Bazaar
|
||||
/// (pardakht) and Myket developer panels. Bound from the "Iab" config section /
|
||||
/// <c>Iab__*</c> env vars.
|
||||
/// </summary>
|
||||
public sealed class IabOptions
|
||||
{
|
||||
/// <summary>Android package name registered in the store panels.</summary>
|
||||
public string PackageName { get; set; } = "com.bargevasat.hokm";
|
||||
|
||||
// ── Cafe Bazaar (pardakht dev API, OAuth refresh-token flow) ──
|
||||
public string BazaarClientId { get; set; } = "";
|
||||
public string BazaarClientSecret { get; set; } = "";
|
||||
public string BazaarRefreshToken { get; set; } = "";
|
||||
|
||||
// ── Myket (developer validation API) ──
|
||||
public string MyketAccessToken { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// DEV ONLY. When true, purchases are credited WITHOUT remote verification
|
||||
/// (use for local testing before you have store credentials). NEVER enable in
|
||||
/// production — it lets a forged token mint coins.
|
||||
/// </summary>
|
||||
public bool AllowUnverified { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a store purchase token (Cafe Bazaar / Myket) server-to-server before
|
||||
/// coins are credited. Endpoints are config-driven; confirm the exact URLs against
|
||||
/// your store panel — the request/response shapes mirror Google Play's IAB API.
|
||||
/// </summary>
|
||||
public sealed class IabService
|
||||
{
|
||||
private static readonly HttpClient Http = new();
|
||||
private readonly IabOptions _opts;
|
||||
private readonly ILogger<IabService> _log;
|
||||
|
||||
public IabService(IabOptions opts, ILogger<IabService> log)
|
||||
{
|
||||
_opts = opts;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<bool> Verify(string store, string productId, string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return _opts.AllowUnverified;
|
||||
store = (store ?? "").Trim().ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
return store switch
|
||||
{
|
||||
"bazaar" or "cafebazaar" => await VerifyBazaar(productId, token),
|
||||
"myket" => await VerifyMyket(productId, token),
|
||||
_ => _opts.AllowUnverified,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "IAB verify failed for store {Store} product {Product}", store, productId);
|
||||
return _opts.AllowUnverified;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cafe Bazaar: exchange the refresh token for an access token, then validate
|
||||
/// the in-app purchase. See https://pardakht.cafebazaar.ir/devapi/v2/.
|
||||
/// </summary>
|
||||
private async Task<bool> VerifyBazaar(string productId, string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_opts.BazaarRefreshToken)) return _opts.AllowUnverified;
|
||||
|
||||
// 1) refresh_token → access_token
|
||||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "refresh_token",
|
||||
["client_id"] = _opts.BazaarClientId,
|
||||
["client_secret"] = _opts.BazaarClientSecret,
|
||||
["refresh_token"] = _opts.BazaarRefreshToken,
|
||||
});
|
||||
var tokenResp = await Http.PostAsync("https://pardakht.cafebazaar.ir/devapi/v2/auth/token/", form);
|
||||
if (!tokenResp.IsSuccessStatusCode) return false;
|
||||
using var tokenDoc = JsonDocument.Parse(await tokenResp.Content.ReadAsStringAsync());
|
||||
if (!tokenDoc.RootElement.TryGetProperty("access_token", out var at)) return false;
|
||||
var access = at.GetString();
|
||||
|
||||
// 2) validate the purchase
|
||||
var url = $"https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{_opts.PackageName}/inapp/{Uri.EscapeDataString(productId)}/purchases/{Uri.EscapeDataString(token)}/?access_token={access}";
|
||||
var vResp = await Http.GetAsync(url);
|
||||
if (!vResp.IsSuccessStatusCode) return false;
|
||||
using var vDoc = JsonDocument.Parse(await vResp.Content.ReadAsStringAsync());
|
||||
// purchaseState: 0 = purchased (1 = refunded/cancelled). Absent ⇒ a 200 body
|
||||
// is itself proof of a valid purchase.
|
||||
if (vDoc.RootElement.TryGetProperty("purchaseState", out var ps) && ps.ValueKind == JsonValueKind.Number)
|
||||
return ps.GetInt32() == 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Myket: validate via the developer API (mirrors Google Play). The access
|
||||
/// token comes from the Myket developer panel.
|
||||
/// </summary>
|
||||
private async Task<bool> VerifyMyket(string productId, string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_opts.MyketAccessToken)) return _opts.AllowUnverified;
|
||||
|
||||
var url = $"https://developer.myket.ir/api/applications/{_opts.PackageName}/purchases/products/{Uri.EscapeDataString(productId)}/tokens/{Uri.EscapeDataString(token)}";
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
req.Headers.Add("X-Access-Token", _opts.MyketAccessToken);
|
||||
var resp = await Http.SendAsync(req);
|
||||
if (!resp.IsSuccessStatusCode) return false;
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
if (doc.RootElement.TryGetProperty("purchaseState", out var ps) && ps.ValueKind == JsonValueKind.Number)
|
||||
return ps.GetInt32() == 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,15 @@ public class StatsDto
|
||||
public int RoundsWon { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Optional social-media handles a player chooses to share.</summary>
|
||||
public class SocialLinksDto
|
||||
{
|
||||
public string? Instagram { get; set; }
|
||||
public string? Telegram { get; set; }
|
||||
public string? X { get; set; }
|
||||
public string? Youtube { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
|
||||
public class ProfileDto
|
||||
{
|
||||
@@ -48,11 +57,42 @@ public class ProfileDto
|
||||
public List<string> Unlocked { get; set; } = new();
|
||||
public long CreatedAt { get; set; }
|
||||
|
||||
// social
|
||||
public string Gender { get; set; } = ""; // "" | male | female | other
|
||||
public SocialLinksDto Socials { get; set; } = new();
|
||||
public string SocialsVisibility { get; set; } = "public"; // public | friends | hidden
|
||||
|
||||
// daily reward streak
|
||||
public int DailyDay { get; set; } = 1;
|
||||
public string? DailyLastClaimed { get; set; } // yyyy-MM-dd
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public-facing view of another player (no coins/phone/email). Mirrors the
|
||||
/// client <c>PublicProfile</c>. Returned by <c>GET /api/profile/{id}/public</c>.
|
||||
/// </summary>
|
||||
public class PublicProfileDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Avatar { get; set; } = "a-fox";
|
||||
public string? AvatarImage { get; set; }
|
||||
public string Plan { get; set; } = "free";
|
||||
public string? Title { get; set; }
|
||||
public int Level { get; set; } = 1;
|
||||
public int Rating { get; set; } = 1000;
|
||||
public StatsDto Stats { get; set; } = new();
|
||||
public Dictionary<string, int> Achievements { get; set; } = new();
|
||||
public List<string> Unlocked { get; set; } = new();
|
||||
public long CreatedAt { get; set; }
|
||||
public string Gender { get; set; } = "";
|
||||
/// <summary>Only populated when the viewer is allowed to see them (public / friend / self).</summary>
|
||||
public SocialLinksDto? Socials { get; set; }
|
||||
public bool IsFriend { get; set; }
|
||||
public bool IsYou { get; set; }
|
||||
public bool RequestSent { get; set; }
|
||||
}
|
||||
|
||||
public class MatchSummaryDto
|
||||
{
|
||||
public bool Ranked { get; set; }
|
||||
|
||||
@@ -64,6 +64,11 @@ public class ProfileService
|
||||
if (patch.TryGetProperty("title", out var ti) && ti.ValueKind == JsonValueKind.String) p.Title = ti.GetString();
|
||||
if (patch.TryGetProperty("cardFront", out var cf) && cf.ValueKind == JsonValueKind.String) p.CardFront = cf.GetString()!;
|
||||
if (patch.TryGetProperty("cardBack", out var cb) && cb.ValueKind == JsonValueKind.String) p.CardBack = cb.GetString()!;
|
||||
// social
|
||||
if (patch.TryGetProperty("gender", out var ge) && ge.ValueKind == JsonValueKind.String) p.Gender = ge.GetString()!;
|
||||
if (patch.TryGetProperty("socialsVisibility", out var sv) && sv.ValueKind == JsonValueKind.String) p.SocialsVisibility = sv.GetString()!;
|
||||
if (patch.TryGetProperty("socials", out var so) && so.ValueKind == JsonValueKind.Object)
|
||||
p.Socials = JsonSerializer.Deserialize<SocialLinksDto>(so.GetRawText(), JsonOpts.Default) ?? p.Socials;
|
||||
return await Save(p);
|
||||
}
|
||||
|
||||
@@ -144,6 +149,7 @@ public class ProfileService
|
||||
"cardback" => p.OwnedCardBacks,
|
||||
"reactionpack" => p.OwnedReactionPacks,
|
||||
"stickerpack" => p.OwnedStickerPacks,
|
||||
"title" => p.OwnedTitles,
|
||||
_ => null,
|
||||
};
|
||||
if (list == null) return (false, null, "bad_kind");
|
||||
@@ -158,7 +164,8 @@ public class ProfileService
|
||||
|
||||
/* ----------------------------- daily ------------------------------ */
|
||||
|
||||
private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 500, 1000 };
|
||||
// Mirror the client DAILY_REWARDS (src/lib/online/gamification.ts) exactly.
|
||||
private static readonly int[] DailyRewards = { 300, 500, 750, 1000, 1500, 2500, 7500 };
|
||||
private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
|
||||
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
|
||||
|
||||
@@ -41,6 +41,11 @@ if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4df
|
||||
builder.Services.AddSingleton(zp);
|
||||
builder.Services.AddSingleton<ZarinpalService>();
|
||||
|
||||
// --- Store in-app billing (Cafe Bazaar / Myket) verification ---
|
||||
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions();
|
||||
builder.Services.AddSingleton(iab);
|
||||
builder.Services.AddSingleton<IabService>();
|
||||
|
||||
// --- SignalR (camelCase to match the TS client) ---
|
||||
builder.Services
|
||||
.AddSignalR()
|
||||
@@ -148,6 +153,19 @@ app.MapPut("/api/profile", async (ClaimsPrincipal u, ProfileService svc, JsonEle
|
||||
Results.Json(await svc.Update(Uid(u), patch), JsonOpts.Default))
|
||||
.RequireAuthorization();
|
||||
|
||||
// Public view of another player (profile card + achievement board).
|
||||
app.MapGet("/api/profile/{id}/public", async (string id, ClaimsPrincipal u, SocialService s) =>
|
||||
{
|
||||
var p = await s.GetPublicProfile(Uid(u), id);
|
||||
return p != null ? Results.Json(p, JsonOpts.Default) : Results.NotFound();
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Discover players (find-friends hub): search by name + suggestions.
|
||||
app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialService s) =>
|
||||
Results.Json(await s.SearchPlayers(Uid(u), q ?? ""), JsonOpts.Default)).RequireAuthorization();
|
||||
app.MapGet("/api/players/suggested", async (ClaimsPrincipal u, SocialService s) =>
|
||||
Results.Json(await s.Suggested(Uid(u)), JsonOpts.Default)).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) =>
|
||||
Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
|
||||
.RequireAuthorization();
|
||||
@@ -189,11 +207,12 @@ app.MapGet("/api/coins/pay/callback", async (string? authority, string? status,
|
||||
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed");
|
||||
});
|
||||
|
||||
// Store in-app purchase (Cafe Bazaar / Myket): the native app sends the purchase
|
||||
// token; we credit the matching pack. (SKU == packId for now.)
|
||||
app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabVerifyReq req) =>
|
||||
// Store in-app purchase (Cafe Bazaar / Myket): the client sends the store purchase
|
||||
// token; we verify it server-to-server, then credit the matching pack (SKU == packId).
|
||||
app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabService iab, IabVerifyReq req) =>
|
||||
{
|
||||
// TODO: verify req.Token with Cafe Bazaar (Pardakht/Poolakey) or Myket dev API.
|
||||
var valid = await iab.Verify(req.Store, req.ProductId, req.Token);
|
||||
if (!valid) return Results.BadRequest(new { ok = false, error = "verification_failed" });
|
||||
var (ok, p, coins) = await svc.BuyCoins(Uid(u), req.ProductId);
|
||||
return ok
|
||||
? Results.Json(new { ok, profile = p, coins }, JsonOpts.Default)
|
||||
@@ -227,7 +246,9 @@ app.MapGet("/api/friends/requests", async (ClaimsPrincipal u, SocialService s) =
|
||||
Results.Json(await s.ListRequests(Uid(u)), JsonOpts.Default)).RequireAuthorization();
|
||||
app.MapPost("/api/friends/add", async (ClaimsPrincipal u, SocialService s, QueryReq r) =>
|
||||
{
|
||||
var (ok, fa, en) = await s.AddFriend(Uid(u), r.Query);
|
||||
var (ok, fa, en) = !string.IsNullOrWhiteSpace(r.UserId)
|
||||
? await s.AddFriendById(Uid(u), r.UserId!)
|
||||
: await s.AddFriend(Uid(u), r.Query ?? "");
|
||||
return Results.Json(new { ok, messageFa = fa, messageEn = en }, JsonOpts.Default);
|
||||
}).RequireAuthorization();
|
||||
app.MapPost("/api/friends/accept", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
|
||||
@@ -263,6 +284,6 @@ record EmailLogin(string Email, string Password, string? Name);
|
||||
record BuyReq(string PackId);
|
||||
record ShopBuyReq(string Kind, string Id, int Price);
|
||||
record IabVerifyReq(string Store, string ProductId, string Token);
|
||||
record QueryReq(string Query);
|
||||
record QueryReq(string? Query = null, string? UserId = null);
|
||||
record IdReq(string Id);
|
||||
record SendReq(string PeerId, string Text);
|
||||
|
||||
@@ -32,3 +32,19 @@ public class ConversationDto
|
||||
public ChatMessageDto? LastMessage { get; set; }
|
||||
public int Unread { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A discoverable player in the social "find friends" hub.</summary>
|
||||
public class PlayerSummaryDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Avatar { get; set; } = "a-fox";
|
||||
public string? AvatarImage { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int Rating { get; set; }
|
||||
public string Status { get; set; } = "offline";
|
||||
public string Gender { get; set; } = "";
|
||||
public string? Title { get; set; }
|
||||
public bool IsFriend { get; set; }
|
||||
public bool RequestSent { get; set; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -23,5 +23,13 @@
|
||||
"Sandbox": true,
|
||||
"CallbackUrl": "http://localhost:5005/api/coins/pay/callback",
|
||||
"ClientReturnUrl": "http://localhost:3000"
|
||||
},
|
||||
"Iab": {
|
||||
"PackageName": "com.bargevasat.hokm",
|
||||
"BazaarClientId": "",
|
||||
"BazaarClientSecret": "",
|
||||
"BazaarRefreshToken": "",
|
||||
"MyketAccessToken": "",
|
||||
"AllowUnverified": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user