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:
soroush.asadi
2026-06-04 16:52:25 +03:30
parent cdb8d522dd
commit d0b8976713
9 changed files with 507 additions and 9 deletions
@@ -0,0 +1,164 @@
namespace Hokm.Server.Profiles;
/// <summary>Server-side port of src/lib/online/gamification.ts.</summary>
public static class Gamification
{
private static readonly (string id, int floor)[] Tiers =
{ ("bronze", 0), ("silver", 1100), ("gold", 1300), ("platinum", 1500), ("diamond", 1700), ("master", 1900) };
private static int RankValue(int rating)
{
int idx = 0;
for (int i = 0; i < Tiers.Length; i++) if (rating >= Tiers[i].floor) idx = i;
if (idx == Tiers.Length - 1) return idx * 10;
double floor = Tiers[idx].floor, next = Tiers[idx + 1].floor, third = (next - floor) / 3.0;
double within = rating - floor;
int division = within < third ? 3 : within < 2 * third ? 2 : 1;
return idx * 10 - division;
}
private const int K = 32;
public static int RatingDelta(MatchSummaryDto s, int my, int opp)
{
if (!s.Ranked) return 0;
double expected = 1.0 / (1.0 + Math.Pow(10, (opp - my) / 400.0));
double delta = K * ((s.Won ? 1 : 0) - expected);
if (s.Won && s.KotFor) delta += 8;
if (!s.Won && s.KotAgainst) delta -= 8;
int r = (int)Math.Round(delta);
return s.Won ? Math.Max(1, r) : Math.Min(-1, r);
}
public static int CoinDelta(MatchSummaryDto s)
{
if (!s.Ranked) return 0;
int kot = s.Won && s.KotFor ? 40 : 0;
return (s.Won ? s.Stake : -s.Stake) + kot;
}
public static int XpForLevel(int level) => 100 * level;
public static int MatchXp(MatchSummaryDto s) =>
40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
private record AchDef(string Id, string NameFa, string NameEn, string Icon, int Goal, int Coin);
private static readonly AchDef[] Achs =
{
new("first_win", "اولین برد", "First Win", "🥇", 1, 100),
new("first_kot", "اولین کُت", "First Kot", "🔥", 1, 150),
new("wins_10", "۱۰ برد", "10 Wins", "🎯", 10, 300),
new("wins_100", "۱۰۰ برد", "100 Wins", "👑", 100, 2000),
new("streak_5", "نوار ۵ برد", "5 Win Streak", "⚡", 5, 400),
new("reach_gold", "رسیدن به طلا", "Reach Gold", "🏅", 1, 500),
new("games_50", "۵۰ بازی", "50 Games", "🎮", 50, 350),
};
private static int AchProgress(string id, StatsDto st, int rating) => id switch
{
"first_win" => Math.Min(1, st.Wins),
"first_kot" => Math.Min(1, st.KotsFor),
"wins_10" => Math.Min(10, st.Wins),
"wins_100" => Math.Min(100, st.Wins),
"streak_5" => Math.Min(5, st.BestWinStreak),
"reach_gold" => rating >= 1300 ? 1 : 0,
"games_50" => Math.Min(50, st.Games),
_ => 0,
};
private record TitleDef(string Id, string NameFa, string NameEn);
private static readonly TitleDef[] Titles =
{
new("novice", "تازه‌کار", "Novice"), new("winner", "برنده", "Winner"),
new("kot_master", "استاد کُت", "Kot Master"), new("veteran", "کهنه‌کار", "Veteran"),
new("champion", "قهرمان", "Champion"), new("legend", "اسطوره", "Legend"),
};
private static bool TitleUnlocked(string id, StatsDto st, int rating, int level) => id switch
{
"novice" => true,
"winner" => st.Wins >= 10,
"kot_master" => st.KotsFor >= 10,
"veteran" => level >= 20,
"champion" => rating >= 1300,
"legend" => rating >= 1900,
_ => false,
};
private static (int level, int xp, bool up) AddXp(int level, int xp, int gain)
{
bool up = false;
xp += gain;
while (xp >= XpForLevel(level)) { xp -= XpForLevel(level); level++; up = true; }
return (level, xp, up);
}
/// <summary>Applies a finished match to the profile (mutates it) and returns the reward breakdown.</summary>
public static RewardResultDto ApplyMatch(ProfileDto p, MatchSummaryDto s, int oppRating)
{
int ratingBefore = p.Rating, coinsBefore = p.Coins, levelBefore = p.Level;
int rDelta = RatingDelta(s, p.Rating, oppRating);
int ratingAfter = Math.Max(0, ratingBefore + rDelta);
int cDelta = CoinDelta(s);
var lvl = AddXp(p.Level, p.Xp, MatchXp(s));
var st = p.Stats;
int cur = s.Won ? st.CurrentWinStreak + 1 : 0;
st.Games += 1;
st.Wins += s.Won ? 1 : 0;
st.Losses += s.Won ? 0 : 1;
st.KotsFor += s.KotFor ? 1 : 0;
st.KotsAgainst += s.KotAgainst ? 1 : 0;
st.Tricks += s.TricksWon;
st.CurrentWinStreak = cur;
st.BestWinStreak = Math.Max(st.BestWinStreak, cur);
var newAch = new List<AchievementUnlockDto>();
int achCoins = 0;
foreach (var d in Achs)
{
int prog = AchProgress(d.Id, st, ratingAfter);
p.Achievements[d.Id] = prog;
if (prog >= d.Goal && !p.Unlocked.Contains(d.Id))
{
p.Unlocked.Add(d.Id);
achCoins += d.Coin;
newAch.Add(new() { Id = d.Id, NameFa = d.NameFa, NameEn = d.NameEn, Icon = d.Icon, CoinReward = d.Coin });
}
}
int coinsAfter = Math.Max(0, coinsBefore + cDelta + achCoins);
var newTitles = new List<TitleUnlockDto>();
foreach (var td in Titles)
if (TitleUnlocked(td.Id, st, ratingAfter, lvl.level) && !p.OwnedTitles.Contains(td.Id))
{
p.OwnedTitles.Add(td.Id);
newTitles.Add(new() { Id = td.Id, NameFa = td.NameFa, NameEn = td.NameEn });
}
bool promoted = RankValue(ratingAfter) > RankValue(ratingBefore);
bool demoted = RankValue(ratingAfter) < RankValue(ratingBefore);
p.Rating = ratingAfter;
p.Coins = coinsAfter;
p.Level = lvl.level;
p.Xp = lvl.xp;
return new RewardResultDto
{
RatingBefore = ratingBefore,
RatingAfter = ratingAfter,
RatingDelta = ratingAfter - ratingBefore,
CoinsBefore = coinsBefore,
CoinsAfter = coinsAfter,
CoinsDelta = coinsAfter - coinsBefore,
XpGained = MatchXp(s),
LevelBefore = levelBefore,
LevelAfter = lvl.level,
LeveledUp = lvl.level > levelBefore,
NewAchievements = newAch,
NewTitles = newTitles,
Promoted = promoted,
Demoted = demoted,
};
}
}
@@ -0,0 +1,110 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Hokm.Server.Profiles;
public class StatsDto
{
public int Games { get; set; }
public int Wins { get; set; }
public int Losses { get; set; }
public int KotsFor { get; set; }
public int KotsAgainst { get; set; }
public int Tricks { get; set; }
public int BestWinStreak { get; set; }
public int CurrentWinStreak { get; set; }
}
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
public class ProfileDto
{
public string Id { get; set; } = "";
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "بازیکن";
public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string Plan { get; set; } = "free";
public long? PlanUntil { get; set; }
public int Level { get; set; } = 1;
public int Xp { get; set; }
public int Coins { get; set; } = 1000;
public int Rating { get; set; } = 1000;
public StatsDto Stats { get; set; } = new();
public List<string> OwnedAvatars { get; set; } = new() { "a-fox", "a-lion" };
public List<string> OwnedCardFronts { get; set; } = new() { "classic" };
public List<string> OwnedCardBacks { get; set; } = new() { "classic" };
public List<string> OwnedTitles { get; set; } = new() { "novice" };
public List<string> OwnedReactionPacks { get; set; } = new();
public List<string> OwnedStickerPacks { get; set; } = new();
public string? Title { get; set; } = "novice";
public string CardFront { get; set; } = "classic";
public string CardBack { get; set; } = "classic";
public Dictionary<string, int> Achievements { get; set; } = new();
public List<string> Unlocked { get; set; } = new();
public long CreatedAt { get; set; }
}
public class MatchSummaryDto
{
public bool Ranked { get; set; }
public int Stake { get; set; }
public bool Won { get; set; }
public bool KotFor { get; set; }
public bool KotAgainst { get; set; }
public int TricksWon { get; set; }
public int Rounds { get; set; }
}
public class AchievementUnlockDto
{
public string Id { get; set; } = "";
public string NameFa { get; set; } = "";
public string NameEn { get; set; } = "";
public string Icon { get; set; } = "";
public int CoinReward { get; set; }
}
public class TitleUnlockDto
{
public string Id { get; set; } = "";
public string NameFa { get; set; } = "";
public string NameEn { get; set; } = "";
}
public class RewardResultDto
{
public int RatingBefore { get; set; }
public int RatingAfter { get; set; }
public int RatingDelta { get; set; }
public int CoinsBefore { get; set; }
public int CoinsAfter { get; set; }
public int CoinsDelta { get; set; }
public int XpGained { get; set; }
public int LevelBefore { get; set; }
public int LevelAfter { get; set; }
public bool LeveledUp { get; set; }
public List<AchievementUnlockDto> NewAchievements { get; set; } = new();
public List<TitleUnlockDto> NewTitles { get; set; } = new();
public bool Promoted { get; set; }
public bool Demoted { get; set; }
}
public class CoinPackDto
{
public string Id { get; set; } = "";
public int Coins { get; set; }
public int Bonus { get; set; }
public int PriceToman { get; set; }
public string? Tag { get; set; }
}
public static class JsonOpts
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}
@@ -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);
}
}