Files
HokmPlay/server/src/Hokm.Server/Profiles/ProfileService.cs
T
soroush.asadi 857287fa84
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 23s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m3s
mobile: fullscreen (immersive Android + PWA) + auto-hide reported nudity avatars
Fullscreen on mobile:
- Android (Capacitor): MainActivity now runs edge-to-edge and hides the status +
  navigation bars (immersive, transient-on-swipe), re-asserted on focus.
- PWA: manifest display -> "fullscreen" with display_override fallback chain;
  viewport gains viewport-fit: cover for proper safe-area/edge-to-edge handling.

Moderation auto-hide:
- ProfileService.ReportUser now de-dupes nudity reports per reporter and, once
  NudityHideThreshold (3) distinct players flag a target's avatar as nudity,
  auto-removes their custom photo (reverts to default avatar). Counted from the
  ledger, so still no schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:32:49 +03:30

323 lines
14 KiB
C#

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<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();
}
/// <summary>Distinct players who must flag an avatar as nudity before it auto-hides.</summary>
public const int NudityHideThreshold = 3;
/// <summary>
/// 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}". Once enough *distinct* players
/// flag a target's avatar as nudity, the custom photo is auto-removed.
/// </summary>
public async Task ReportUser(string reporterUid, string targetId, string? reason, string? details)
{
if (string.IsNullOrWhiteSpace(targetId) || targetId == reporterUid) 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];
var nudityPrefix = targetId + "|nudity";
// De-dupe nudity reports so a single player can't nuke an avatar alone.
if (safeReason == "nudity")
{
var already = await _db.Ledger.AnyAsync(
l => l.Kind == "report" && l.UserId == reporterUid && l.Ref!.StartsWith(nudityPrefix));
if (already) return;
}
await Ledger(reporterUid, "report", 0, @ref);
// Auto-hide a custom avatar once enough distinct players flag it.
if (safeReason == "nudity")
{
var reporters = await _db.Ledger
.Where(l => l.Kind == "report" && l.Ref!.StartsWith(nudityPrefix))
.Select(l => l.UserId)
.Distinct()
.CountAsync();
if (reporters >= NudityHideThreshold)
{
var target = await GetOrCreate(targetId, null);
if (!string.IsNullOrEmpty(target.AvatarImage))
{
target.AvatarImage = null; // revert to their default avatar
await SaveInternal(target);
}
}
}
}
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()!;
// 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<SocialLinksDto>(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;
}
/// <summary>One-time coin reward for setting your city (mirrors client CITY_REWARD).</summary>
public const int CityReward = 500;
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);
}
/// <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 ------------------------------- */
// Coin-priced XP packs (XP is intentionally expensive). Server-authoritative.
public static readonly Dictionary<string, (int Price, int Xp)> XpPacks = new()
{
["xp1"] = (1500, 200),
["xp2"] = (4000, 600),
["xp3"] = (8000, 1500),
};
// Gated gifts encode their tier in the id (`-t<n>-`); 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<string, (int Level, int Rating, string? Ach)> 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);
}
}