2026-06-04 16:52:25 +03:30
|
|
|
|
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;
|
2026-06-04 21:47:38 +03:30
|
|
|
|
// Kot bonus scales with the league stake (mirrors gamification.ts).
|
|
|
|
|
|
int kot = s.Won && s.KotFor ? (int)Math.Round(s.Stake * 0.4) : 0;
|
2026-06-04 16:52:25 +03:30
|
|
|
|
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);
|
|
|
|
|
|
|
2026-06-04 21:47:38 +03:30
|
|
|
|
// metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach.
|
|
|
|
|
|
private record AchDef(string Id, string? Metric, int RatingFloor, int Goal, int Coin, string NameFa, string NameEn, string Icon);
|
2026-06-04 22:47:36 +03:30
|
|
|
|
// Mirrors src/lib/online/gamification.ts (same ids/goals/coins/metrics).
|
|
|
|
|
|
private static int Coin(int g) => Math.Max(100, (int)Math.Floor((80.0 + g * 12) / 50.0 + 0.5) * 50);
|
|
|
|
|
|
private static string Fa(int n) =>
|
|
|
|
|
|
new string(n.ToString().Select(c => c is >= '0' and <= '9' ? "۰۱۲۳۴۵۶۷۸۹"[c - '0'] : c).ToArray());
|
|
|
|
|
|
|
|
|
|
|
|
private static AchDef[] Tier(string metric, string prefix, string icon, int[] goals, Func<int, string> faName, Func<int, string> enName)
|
|
|
|
|
|
=> goals.Select(g => new AchDef($"{prefix}_{g}", metric, 0, g, Coin(g), faName(g), enName(g), icon)).ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
private static readonly AchDef[] Achs = BuildAchs();
|
|
|
|
|
|
private static AchDef[] BuildAchs()
|
2026-06-04 16:52:25 +03:30
|
|
|
|
{
|
2026-06-04 22:47:36 +03:30
|
|
|
|
var l = new List<AchDef>();
|
|
|
|
|
|
l.AddRange(Tier("wins", "wins", "🏆", new[] { 1, 5, 10, 25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 2000 }, g => $"{Fa(g)} برد", g => $"{g} Wins"));
|
|
|
|
|
|
l.AddRange(Tier("shutoutWins", "shutout", "🧹", new[] { 1, 3, 5, 10, 25, 50, 100 }, g => $"{Fa(g)} بار هفت–هیچ", g => $"{g}× Sweep"));
|
|
|
|
|
|
l.AddRange(Tier("kotsFor", "kot", "🔥", new[] { 1, 3, 5, 10, 25, 50, 75, 100, 150, 200, 300, 500 }, g => $"{Fa(g)} کُت", g => $"{g} Kots"));
|
|
|
|
|
|
l.AddRange(Tier("bestWinStreak", "streak", "⚡", new[] { 2, 3, 5, 7, 10, 15, 20, 25, 30, 40 }, g => $"{Fa(g)} برد پیاپی", g => $"{g} Win Streak"));
|
|
|
|
|
|
l.AddRange(Tier("hakemRounds", "hakem", "👑", new[] { 7, 25, 50, 100, 250, 500, 1000 }, g => $"{Fa(g)} بار حاکم", g => $"Hakem {g}×"));
|
|
|
|
|
|
l.AddRange(Tier("level", "level", "⭐", new[] { 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100 }, g => $"سطح {Fa(g)}", g => $"Level {g}"));
|
|
|
|
|
|
l.AddRange(Tier("games", "games", "🎮", new[] { 10, 25, 50, 100, 200, 300, 500, 750, 1000, 2000, 5000 }, g => $"{Fa(g)} بازی", g => $"{g} Games"));
|
|
|
|
|
|
l.AddRange(Tier("roundsWon", "rounds", "🎴", new[] { 25, 100, 250, 500, 1000, 2000, 5000 }, g => $"{Fa(g)} دست برده", g => $"{g} Rounds Won"));
|
|
|
|
|
|
l.AddRange(Tier("tricks", "tricks", "🗂️", new[] { 50, 100, 250, 500, 1000, 2500, 5000, 10000 }, g => $"{Fa(g)} دستبرد", g => $"{g} Tricks"));
|
|
|
|
|
|
l.AddRange(Tier("losses", "grit", "🛡️", new[] { 10, 50, 100 }, g => $"{Fa(g)} باخت", g => $"{g} Losses"));
|
|
|
|
|
|
l.Add(new AchDef("reach_silver", null, 1100, 1, 200, "لیگ نقره", "Reach Silver", "🥈"));
|
|
|
|
|
|
l.Add(new AchDef("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇"));
|
|
|
|
|
|
l.Add(new AchDef("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️"));
|
|
|
|
|
|
l.Add(new AchDef("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠"));
|
|
|
|
|
|
l.Add(new AchDef("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑"));
|
|
|
|
|
|
return l.ToArray();
|
|
|
|
|
|
}
|
2026-06-04 16:52:25 +03:30
|
|
|
|
|
2026-06-04 21:47:38 +03:30
|
|
|
|
private static int Metric(string m, StatsDto st, int level) => m switch
|
2026-06-04 16:52:25 +03:30
|
|
|
|
{
|
2026-06-04 21:47:38 +03:30
|
|
|
|
"wins" => st.Wins,
|
2026-06-04 22:47:36 +03:30
|
|
|
|
"losses" => st.Losses,
|
2026-06-04 21:47:38 +03:30
|
|
|
|
"kotsFor" => st.KotsFor,
|
|
|
|
|
|
"bestWinStreak" => st.BestWinStreak,
|
|
|
|
|
|
"shutoutWins" => st.ShutoutWins,
|
|
|
|
|
|
"games" => st.Games,
|
|
|
|
|
|
"tricks" => st.Tricks,
|
2026-06-04 22:47:36 +03:30
|
|
|
|
"hakemRounds" => st.HakemRounds,
|
|
|
|
|
|
"roundsWon" => st.RoundsWon,
|
2026-06-04 21:47:38 +03:30
|
|
|
|
"level" => level,
|
2026-06-04 16:52:25 +03:30
|
|
|
|
_ => 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 21:47:38 +03:30
|
|
|
|
private static int AchProgress(AchDef d, StatsDto st, int rating, int level)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (d.RatingFloor > 0) return rating >= d.RatingFloor ? d.Goal : 0;
|
|
|
|
|
|
if (d.Metric == null) return 0;
|
|
|
|
|
|
return Math.Min(d.Goal, Metric(d.Metric, st, level));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 16:52:25 +03:30
|
|
|
|
private record TitleDef(string Id, string NameFa, string NameEn);
|
|
|
|
|
|
private static readonly TitleDef[] Titles =
|
|
|
|
|
|
{
|
|
|
|
|
|
new("novice", "تازهکار", "Novice"), new("winner", "برنده", "Winner"),
|
2026-06-04 21:47:38 +03:30
|
|
|
|
new("expert", "خبره", "Expert"), new("kot_master", "استاد کُت", "Kot Master"),
|
|
|
|
|
|
new("professional", "حرفهای", "Professional"), new("veteran", "کهنهکار", "Veteran"),
|
|
|
|
|
|
new("captain", "کاپیتان", "Captain"), new("champion", "قهرمان", "Champion"),
|
|
|
|
|
|
new("leader", "فرمانده", "Leader"), new("legend", "اسطوره", "Legend"),
|
2026-06-04 16:52:25 +03:30
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private static bool TitleUnlocked(string id, StatsDto st, int rating, int level) => id switch
|
|
|
|
|
|
{
|
|
|
|
|
|
"novice" => true,
|
|
|
|
|
|
"winner" => st.Wins >= 10,
|
2026-06-04 21:47:38 +03:30
|
|
|
|
"expert" => level >= 25,
|
|
|
|
|
|
"kot_master" => st.KotsFor >= 25,
|
|
|
|
|
|
"professional" => st.Wins >= 50,
|
|
|
|
|
|
"veteran" => level >= 30,
|
|
|
|
|
|
"captain" => st.Wins >= 100,
|
2026-06-04 16:52:25 +03:30
|
|
|
|
"champion" => rating >= 1300,
|
2026-06-04 21:47:38 +03:30
|
|
|
|
"leader" => st.Wins >= 250,
|
2026-06-04 16:52:25 +03:30
|
|
|
|
"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);
|
2026-06-04 21:47:38 +03:30
|
|
|
|
st.ShutoutWins += s.Won && s.Shutout ? 1 : 0;
|
2026-06-04 22:47:36 +03:30
|
|
|
|
st.HakemRounds += s.HakemRounds;
|
|
|
|
|
|
st.RoundsWon += s.RoundsWon;
|
2026-06-04 16:52:25 +03:30
|
|
|
|
|
|
|
|
|
|
var newAch = new List<AchievementUnlockDto>();
|
|
|
|
|
|
int achCoins = 0;
|
|
|
|
|
|
foreach (var d in Achs)
|
|
|
|
|
|
{
|
2026-06-04 21:47:38 +03:30
|
|
|
|
int prog = AchProgress(d, st, ratingAfter, lvl.level);
|
2026-06-04 16:52:25 +03:30
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|