Files
HokmPlay/server/src/Hokm.Server/Profiles/Gamification.cs
T

220 lines
10 KiB
C#
Raw Normal View History

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;
// Kot bonus scales with the league stake (mirrors gamification.ts).
int kot = s.Won && s.KotFor ? (int)Math.Round(s.Stake * 0.4) : 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);
// 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);
private static readonly AchDef[] Achs =
{
// victories
new("first_win", "wins", 0, 1, 100, "اولین برد", "First Win", "🥇"),
new("wins_10", "wins", 0, 10, 300, "۱۰ برد", "10 Wins", "🎯"),
new("wins_25", "wins", 0, 25, 600, "۲۵ برد", "25 Wins", "🏅"),
new("wins_50", "wins", 0, 50, 1000, "۵۰ برد", "50 Wins", "🏆"),
new("wins_100", "wins", 0, 100, 2000, "۱۰۰ برد", "100 Wins", "👑"),
new("wins_250", "wins", 0, 250, 4000, "۲۵۰ برد", "250 Wins", "💎"),
new("wins_500", "wins", 0, 500, 8000, "۵۰۰ برد", "500 Wins", "🌟"),
new("shutout_1", "shutoutWins", 0, 1, 400, "هفت–هیچ", "SevenZip", "🧹"),
new("shutout_5", "shutoutWins", 0, 5, 900, "۵ بار هفت–هیچ", "5× Sweep", "🧨"),
new("shutout_25", "shutoutWins", 0, 25, 3000, "۲۵ بار هفت–هیچ", "25× Sweep", "☄️"),
// kot
new("first_kot", "kotsFor", 0, 1, 150, "اولین کُت", "First Kot", "🔥"),
new("kot_5", "kotsFor", 0, 5, 300, "۵ کُت", "5 Kots", "🌶️"),
new("kot_10", "kotsFor", 0, 10, 500, "۱۰ کُت", "10 Kots", "🔥"),
new("kot_25", "kotsFor", 0, 25, 1200, "۲۵ کُت", "25 Kots", "💥"),
new("kot_50", "kotsFor", 0, 50, 2500, "۵۰ کُت", "50 Kots", "⚡"),
new("kot_100", "kotsFor", 0, 100, 5000, "۱۰۰ کُت", "100 Kots", "👹"),
// streaks
new("streak_3", "bestWinStreak", 0, 3, 200, "۳ برد پیاپی", "3 Win Streak", "➡️"),
new("streak_5", "bestWinStreak", 0, 5, 400, "۵ برد پیاپی", "5 Win Streak", "⚡"),
new("streak_10", "bestWinStreak", 0, 10, 1000, "۱۰ برد پیاپی", "10 Win Streak", "🌊"),
new("streak_15", "bestWinStreak", 0, 15, 2000, "۱۵ برد پیاپی", "15 Win Streak", "🚀"),
// levels
new("level_5", "level", 0, 5, 150, "سطح ۵", "Level 5", "⭐"),
new("level_10", "level", 0, 10, 300, "سطح ۱۰", "Level 10", "🌟"),
new("level_15", "level", 0, 15, 500, "سطح ۱۵", "Level 15", "✨"),
new("level_20", "level", 0, 20, 800, "سطح ۲۰", "Level 20", "💫"),
new("level_25", "level", 0, 25, 1200, "سطح ۲۵", "Level 25", "🔆"),
new("level_30", "level", 0, 30, 1600, "سطح ۳۰", "Level 30", "🎖️"),
new("level_40", "level", 0, 40, 2500, "سطح ۴۰", "Level 40", "🏵️"),
new("level_50", "level", 0, 50, 4000, "سطح ۵۰", "Level 50", "🌠"),
// ranks
new("reach_silver", null, 1100, 1, 200, "لیگ نقره", "Reach Silver", "🥈"),
new("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇"),
new("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️"),
new("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠"),
new("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑"),
// veterancy
new("games_10", "games", 0, 10, 150, "۱۰ بازی", "10 Games", "🎮"),
new("games_50", "games", 0, 50, 350, "۵۰ بازی", "50 Games", "🕹️"),
new("games_200", "games", 0, 200, 1200, "۲۰۰ بازی", "200 Games", "🎲"),
new("games_500", "games", 0, 500, 3000, "۵۰۰ بازی", "500 Games", "🃏"),
new("games_1000", "games", 0, 1000, 7000, "۱۰۰۰ بازی", "1000 Games", "♾️"),
new("tricks_100", "tricks", 0, 100, 300, "۱۰۰ دست", "100 Tricks", "🎴"),
new("tricks_1000", "tricks", 0, 1000, 2000, "۱۰۰۰ دست", "1000 Tricks", "🗂️"),
};
private static int Metric(string m, StatsDto st, int level) => m switch
{
"wins" => st.Wins,
"kotsFor" => st.KotsFor,
"bestWinStreak" => st.BestWinStreak,
"shutoutWins" => st.ShutoutWins,
"games" => st.Games,
"tricks" => st.Tricks,
"level" => level,
_ => 0,
};
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));
}
private record TitleDef(string Id, string NameFa, string NameEn);
private static readonly TitleDef[] Titles =
{
new("novice", "تازه‌کار", "Novice"), new("winner", "برنده", "Winner"),
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"),
};
private static bool TitleUnlocked(string id, StatsDto st, int rating, int level) => id switch
{
"novice" => true,
"winner" => st.Wins >= 10,
"expert" => level >= 25,
"kot_master" => st.KotsFor >= 25,
"professional" => st.Wins >= 50,
"veteran" => level >= 30,
"captain" => st.Wins >= 100,
"champion" => rating >= 1300,
"leader" => st.Wins >= 250,
"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);
st.ShutoutWins += s.Won && s.Shutout ? 1 : 0;
var newAch = new List<AchievementUnlockDto>();
int achCoins = 0;
foreach (var d in Achs)
{
int prog = AchProgress(d, st, ratingAfter, lvl.level);
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,
};
}
}