Files
HokmPlay/server/src/Hokm.Server/Profiles/ProfileService.cs
T
soroush.asadi 4f2e4e14ea Server-authoritative economy: wire client to server; entry + rewards on hub
Server:
- daily (/api/daily, /api/daily/claim) + shop (/api/shop/buy) + ChargeEntry
- GameRoom (via IServiceScopeFactory) deducts ranked entry at match start and
  applies match rewards at match-over, broadcasting profile + reward over the hub
- tested: daily, shop (owned-guard), ranked entry deduction pushed over hub

Client:
- SignalrService routes profile/coins/plan/daily/shop/match to the server (Bearer);
  onProfile/onReward hub events; guest/offline fall back to local
- session-store syncs profile from hub; game-store serverReward; GameScreen shows
  live ranked reward from hub (no double submit), submits client-run games
- single source of truth in live mode (no economy divergence)

Postgres-ready via config (Provider=postgres); EnsureCreated for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:32:47 +03:30

161 lines
6.5 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 = 1000, Bonus = 0, PriceToman = 19000 },
new() { Id = "p2", Coins = 5000, Bonus = 500, PriceToman = 89000, Tag = "popular" },
new() { Id = "p3", Coins = 12000, Bonus = 2000, PriceToman = 179000, Tag = "best" },
new() { Id = "p4", Coins = 30000, Bonus = 7000, PriceToman = 399000 },
};
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()!;
if (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()!;
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);
}
/// <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 ------------------------------- */
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
{
var p = await GetOrCreate(uid, null);
var list = kind switch
{
"avatar" => p.OwnedAvatars,
"cardfront" => p.OwnedCardFronts,
"cardback" => p.OwnedCardBacks,
"reactionpack" => p.OwnedReactionPacks,
"stickerpack" => p.OwnedStickerPacks,
_ => 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 ------------------------------ */
private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 500, 1000 };
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);
}
}