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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user