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 = { new() { Id = "p1", Coins = 5000, Bonus = 0, PriceToman = 99000, Tag = "starter" }, new() { Id = "p2", Coins = 11000, Bonus = 1000, PriceToman = 199000, Tag = "popular" }, new() { Id = "p3", Coins = 24000, Bonus = 4000, PriceToman = 399000, Tag = "best" }, new() { Id = "p4", Coins = 50000, Bonus = 15000, PriceToman = 799000 }, }; 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 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(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 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(); } /// /// Record a moderation report (inappropriate avatar / insulting chat). Stored /// in the write-only ledger as kind="report" so no schema change is needed; /// Ref encodes "{targetId}|{reason}|{details}". /// public async Task ReportUser(string reporterUid, string targetId, string? reason, string? details) { if (string.IsNullOrWhiteSpace(targetId)) return; var safeReason = reason is "nudity" or "insult" or "other" ? reason : "other"; var safeDetails = (details ?? "").Replace("\n", " ").Trim(); var @ref = $"{targetId}|{safeReason}|{safeDetails}"; if (@ref.Length > 480) @ref = @ref[..480]; await Ledger(reporterUid, "report", 0, @ref); } public async Task 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()!; // Custom photo upload is gated behind level 3. if (p.Level >= 3 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString(); 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(so.GetRawText(), JsonOpts.Default) ?? p.Socials; // One-time "set your city" reward: first non-empty city → +500 coins. var cityRewarded = false; if (patch.TryGetProperty("city", out var ci) && ci.ValueKind == JsonValueKind.String) { var city = ci.GetString(); p.City = city; if (!string.IsNullOrWhiteSpace(city) && !p.CityRewardClaimed) { p.CityRewardClaimed = true; p.Coins += CityReward; cityRewarded = true; } } await Save(p); if (cityRewarded) await Ledger(uid, "city", CityReward, "profile-city"); return p; } /// One-time coin reward for setting your city (mirrors client CITY_REWARD). public const int CityReward = 500; public async Task 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); } /// Deduct a ranked entry/stake up front. Returns null if not enough coins. public async Task 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 ------------------------------- */ // Coin-priced XP packs (XP is intentionally expensive). Server-authoritative. public static readonly Dictionary XpPacks = new() { ["xp1"] = (1500, 200), ["xp2"] = (4000, 600), ["xp3"] = (8000, 1500), }; // Gated gifts encode their tier in the id (`-t-`); the gate is derived from // the tier so the server enforces it without a 100-entry catalog mirror. // Mirrors GIFT_TIERS in src/lib/online/types.ts. private static readonly (int Level, int Rating)[] GiftGate = { (0, 0), (0, 0), (10, 0), (20, 0), (35, 0), (0, 1700) }; // index = tier (1..5) private static (int Level, int Rating) GiftGateFor(string id) { var m = System.Text.RegularExpressions.Regex.Match(id, @"-t(\d)-"); if (m.Success && int.TryParse(m.Groups[1].Value, out var tier) && tier >= 1 && tier <= 5) return GiftGate[tier]; return (0, 0); } // Per-item purchase gates for the named (non-tier-encoded) cosmetics, keyed by // "kind:id" since ids repeat across kinds (e.g. "taunt" is both a reaction & // sticker pack). Every one is still coin-priced — this only gates the purchase. // ⚠️ Mirror of req* in src/lib/online/types.ts (AVATARS) + gamification.ts // (CARD_BACKS/FRONTS, REACTION_PACKS, STICKER_PACKS). Keep both in sync. private static readonly Dictionary ItemGate = new() { // avatars ["avatar:a-robot"] = (0, 0, "wins_50"), ["avatar:a-wizard"] = (0, 1300, null), ["avatar:a-ninja"] = (0, 0, "wins_100"), ["avatar:a-king"] = (0, 1500, null), ["avatar:a-genie"] = (0, 1700, null), ["avatar:a-crown"] = (0, 1900, "hakem_7"), ["avatar:a-gem"] = (0, 2100, "shutout_10"), // card backs ["cardback:crimson"] = (0, 0, "wins_25"), ["cardback:ruby"] = (0, 1300, null), ["cardback:royal"] = (0, 0, "wins_50"), ["cardback:aurora"] = (0, 1500, null), ["cardback:obsidian"] = (0, 1700, null), ["cardback:imperial"] = (0, 1900, "hakem_7"), // card fronts ["cardfront:parchment"] = (0, 1300, null), ["cardfront:mint"] = (0, 0, "wins_50"), ["cardfront:goldleaf"] = (0, 1500, null), ["cardfront:crystal"] = (0, 1700, null), ["cardfront:imperial-face"] = (0, 0, "wins_100"), // reaction packs ["reactionpack:champion"] = (0, 1300, null), ["reactionpack:legend"] = (0, 0, "wins_100"), // sticker packs ["stickerpack:hokm"] = (0, 0, "shutout_1"), ["stickerpack:persian"] = (0, 0, "wins_100"), ["stickerpack:taunt"] = (0, 0, "kot_25"), ["stickerpack:rulership"] = (0, 0, "hakem_7"), ["stickerpack:firestorm"] = (0, 0, "streak_10"), ["stickerpack:victory"] = (0, 1500, null), ["stickerpack:raghib"] = (0, 0, "kot_10"), }; public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price) { var p = await GetOrCreate(uid, null); // Gated gift: locked until the player meets the tier's level/rating gate. var gate = GiftGateFor(id); if (p.Level < gate.Level || p.Rating < gate.Rating) return (false, p, "locked"); // Named-item gate (avatars/backs/fronts/reactions/stickers): coin-priced but // locked until the level / rating / achievement requirement is met. if (ItemGate.TryGetValue($"{kind}:{id}", out var ig) && (p.Level < ig.Level || p.Rating < ig.Rating || (ig.Ach != null && !p.Unlocked.Contains(ig.Ach)))) return (false, p, "locked"); // 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); Gamification.EvaluateAchievements(p); // unlock any level milestones reached await Save(p); await Ledger(uid, "xp", -pk.Price, id); return (true, p, ""); } var list = kind switch { "avatar" => p.OwnedAvatars, "cardfront" => p.OwnedCardFronts, "cardback" => p.OwnedCardBacks, "reactionpack" => p.OwnedReactionPacks, "stickerpack" => p.OwnedStickerPacks, "title" => p.OwnedTitles, _ => 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 ------------------------------ */ // Mirror the client DAILY_REWARDS (src/lib/online/gamification.ts) exactly. private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 600, 1500 }; 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); } }