2026-06-04 16:52:25 +03:30
|
|
|
using System.Text.Json;
|
|
|
|
|
using Hokm.Server.Data;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
|
|
|
|
|
namespace Hokm.Server.Profiles;
|
|
|
|
|
|
|
|
|
|
public class ProfileService
|
|
|
|
|
{
|
|
|
|
|
private readonly AppDbContext _db;
|
|
|
|
|
public ProfileService(AppDbContext db) => _db = db;
|
|
|
|
|
|
|
|
|
|
public static readonly CoinPackDto[] Packs =
|
|
|
|
|
{
|
2026-06-04 22:47:36 +03:30
|
|
|
new() { Id = "p1", Coins = 50000, Bonus = 0, PriceToman = 95000, Tag = "starter" },
|
|
|
|
|
new() { Id = "p2", Coins = 120000, Bonus = 15000, PriceToman = 189000, Tag = "popular" },
|
|
|
|
|
new() { Id = "p3", Coins = 300000, Bonus = 50000, PriceToman = 389000, Tag = "best" },
|
|
|
|
|
new() { Id = "p4", Coins = 700000, Bonus = 150000, PriceToman = 790000 },
|
2026-06-04 16:52:25 +03:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static ProfileDto Default(string userId, string? name) => new()
|
|
|
|
|
{
|
|
|
|
|
Id = userId,
|
|
|
|
|
Username = "player_" + (userId.Length >= 4 ? userId[^4..] : userId),
|
|
|
|
|
DisplayName = string.IsNullOrWhiteSpace(name) ? "بازیکن" : name!,
|
|
|
|
|
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
public async Task<ProfileDto> GetOrCreate(string userId, string? name)
|
|
|
|
|
{
|
|
|
|
|
var row = await _db.Profiles.FindAsync(userId);
|
|
|
|
|
if (row == null)
|
|
|
|
|
{
|
|
|
|
|
var dto = Default(userId, name);
|
|
|
|
|
await SaveInternal(dto);
|
|
|
|
|
return dto;
|
|
|
|
|
}
|
|
|
|
|
return JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default) ?? Default(userId, name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task SaveInternal(ProfileDto p)
|
|
|
|
|
{
|
|
|
|
|
var json = JsonSerializer.Serialize(p, JsonOpts.Default);
|
|
|
|
|
var row = await _db.Profiles.FindAsync(p.Id);
|
|
|
|
|
if (row == null) _db.Profiles.Add(new ProfileRow { Id = p.Id, Json = json, UpdatedAt = DateTime.UtcNow });
|
|
|
|
|
else { row.Json = json; row.UpdatedAt = DateTime.UtcNow; }
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<ProfileDto> Save(ProfileDto p) { await SaveInternal(p); return p; }
|
|
|
|
|
|
|
|
|
|
private async Task Ledger(string uid, string kind, int amount, string? @ref)
|
|
|
|
|
{
|
|
|
|
|
_db.Ledger.Add(new LedgerRow { UserId = uid, Kind = kind, Amount = amount, Ref = @ref, CreatedAt = DateTime.UtcNow });
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<ProfileDto> Update(string uid, JsonElement patch)
|
|
|
|
|
{
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
|
|
|
|
if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!;
|
|
|
|
|
if (patch.TryGetProperty("avatar", out var av) && av.ValueKind == JsonValueKind.String) p.Avatar = av.GetString()!;
|
2026-06-04 21:47:38 +03:30
|
|
|
// Custom photo upload is gated behind level 25.
|
|
|
|
|
if (p.Level >= 25 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
|
2026-06-04 16:52:25 +03:30
|
|
|
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()!;
|
2026-06-06 18:39:24 +03:30
|
|
|
// 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;
|
2026-06-04 16:52:25 +03:30
|
|
|
return await Save(p);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<ProfileDto> UpgradePlan(string uid)
|
|
|
|
|
{
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
|
|
|
|
p.Plan = "pro";
|
|
|
|
|
p.PlanUntil = DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeMilliseconds();
|
|
|
|
|
return await Save(p);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<(bool ok, ProfileDto? profile, int coins)> BuyCoins(string uid, string packId)
|
|
|
|
|
{
|
|
|
|
|
var pack = Packs.FirstOrDefault(x => x.Id == packId);
|
|
|
|
|
if (pack == null) return (false, null, 0);
|
|
|
|
|
// NOTE: real payment (Zarinpal/IDPay) verification goes here.
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
|
|
|
|
int added = pack.Coins + pack.Bonus;
|
|
|
|
|
p.Coins += added;
|
|
|
|
|
await Save(p);
|
|
|
|
|
await Ledger(uid, "purchase", added, packId);
|
|
|
|
|
return (true, p, added);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<(RewardResultDto reward, ProfileDto profile)> ApplyMatch(string uid, MatchSummaryDto s)
|
|
|
|
|
{
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
|
|
|
|
// No real opponent rating tracked yet — treat as an even match.
|
|
|
|
|
var reward = Gamification.ApplyMatch(p, s, p.Rating);
|
|
|
|
|
await Save(p);
|
|
|
|
|
await Ledger(uid, "match", reward.CoinsDelta, s.Ranked ? "ranked" : "casual");
|
|
|
|
|
return (reward, p);
|
|
|
|
|
}
|
2026-06-04 17:32:47 +03:30
|
|
|
|
|
|
|
|
/// <summary>Deduct a ranked entry/stake up front. Returns null if not enough coins.</summary>
|
|
|
|
|
public async Task<ProfileDto?> ChargeEntry(string uid, int amount)
|
|
|
|
|
{
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
|
|
|
|
if (amount <= 0) return p;
|
|
|
|
|
if (p.Coins < amount) return null;
|
|
|
|
|
p.Coins -= amount;
|
|
|
|
|
await Save(p);
|
|
|
|
|
await Ledger(uid, "entry", -amount, "ranked");
|
|
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------- shop ------------------------------- */
|
|
|
|
|
|
2026-06-05 00:08:19 +03:30
|
|
|
// Coin-priced XP packs (XP is intentionally expensive). Server-authoritative.
|
|
|
|
|
public static readonly Dictionary<string, (int Price, int Xp)> XpPacks = new()
|
|
|
|
|
{
|
|
|
|
|
["xp1"] = (5000, 200),
|
|
|
|
|
["xp2"] = (12000, 600),
|
|
|
|
|
["xp3"] = (25000, 1500),
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-04 17:32:47 +03:30
|
|
|
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
|
|
|
|
|
{
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
2026-06-05 00:08:19 +03:30
|
|
|
|
|
|
|
|
// XP packs are consumable (grant XP, may level up) — not added to an owned list.
|
|
|
|
|
if (kind == "xp")
|
|
|
|
|
{
|
|
|
|
|
if (!XpPacks.TryGetValue(id, out var pk)) return (false, p, "bad_kind");
|
|
|
|
|
if (p.Coins < pk.Price) return (false, p, "insufficient");
|
|
|
|
|
p.Coins -= pk.Price;
|
|
|
|
|
Gamification.GrantXp(p, pk.Xp);
|
2026-06-05 09:52:28 +03:30
|
|
|
Gamification.EvaluateAchievements(p); // unlock any level milestones reached
|
2026-06-05 00:08:19 +03:30
|
|
|
await Save(p);
|
|
|
|
|
await Ledger(uid, "xp", -pk.Price, id);
|
|
|
|
|
return (true, p, "");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 17:32:47 +03:30
|
|
|
var list = kind switch
|
|
|
|
|
{
|
|
|
|
|
"avatar" => p.OwnedAvatars,
|
|
|
|
|
"cardfront" => p.OwnedCardFronts,
|
|
|
|
|
"cardback" => p.OwnedCardBacks,
|
|
|
|
|
"reactionpack" => p.OwnedReactionPacks,
|
|
|
|
|
"stickerpack" => p.OwnedStickerPacks,
|
2026-06-06 18:39:24 +03:30
|
|
|
"title" => p.OwnedTitles,
|
2026-06-04 17:32:47 +03:30
|
|
|
_ => null,
|
|
|
|
|
};
|
|
|
|
|
if (list == null) return (false, null, "bad_kind");
|
|
|
|
|
if (list.Contains(id)) return (false, p, "owned");
|
|
|
|
|
if (price < 0 || p.Coins < price) return (false, p, "insufficient");
|
|
|
|
|
p.Coins -= price;
|
|
|
|
|
list.Add(id);
|
|
|
|
|
await Save(p);
|
|
|
|
|
await Ledger(uid, "shop", -price, $"{kind}:{id}");
|
|
|
|
|
return (true, p, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------- daily ------------------------------ */
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
// Mirror the client DAILY_REWARDS (src/lib/online/gamification.ts) exactly.
|
|
|
|
|
private static readonly int[] DailyRewards = { 300, 500, 750, 1000, 1500, 2500, 7500 };
|
2026-06-04 17:32:47 +03:30
|
|
|
private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
|
|
|
|
|
{
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
|
|
|
|
return (p.DailyDay, p.DailyLastClaimed, p.DailyLastClaimed != Today);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<(int reward, ProfileDto profile, int day)> ClaimDaily(string uid)
|
|
|
|
|
{
|
|
|
|
|
var p = await GetOrCreate(uid, null);
|
|
|
|
|
if (p.DailyLastClaimed == Today) return (0, p, p.DailyDay);
|
|
|
|
|
int day = p.DailyDay;
|
|
|
|
|
int reward = DailyRewards[Math.Min(day, DailyRewards.Length) - 1];
|
|
|
|
|
p.Coins += reward;
|
|
|
|
|
p.DailyDay = day >= 7 ? 1 : day + 1;
|
|
|
|
|
p.DailyLastClaimed = Today;
|
|
|
|
|
await Save(p);
|
|
|
|
|
await Ledger(uid, "daily", reward, "day" + day);
|
|
|
|
|
return (reward, p, day);
|
|
|
|
|
}
|
2026-06-04 16:52:25 +03:30
|
|
|
}
|