Server persistence: EF Core profiles + coin ledger + authoritative rewards
- EF Core (SQLite dev / Postgres prod via config); ProfileRow JSON blob + LedgerRow audit; EnsureCreated at startup - C# Gamification port (ranks/elo/coins/xp/achievements/titles) → server computes match rewards; ProfileService (get/update/plan/buyCoins/applyMatch) - JWT endpoints: profile GET/PUT, plan, coins packs/buy, match/result; auth upserts the profile - Tested end-to-end (buy + ranked win+kot persisted & server-computed) - Client still mock-backed for now (wiring is the next step) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user