Files
HokmPlay/server/src/Hokm.Server/Profiles/Gamification.cs
T
soroush.asadi b66e7f77a5
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m20s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped
100+ achievements, forfeit, leagues floor, bot humanize, 95k starter
Achievements: generator-driven, now 100+ across 7 categories (added Rulership)
mirrored client + server with identical ids/goals/coins. New tracked stats:
hakemRounds (be the hakem — incl. "7× Hakem"), roundsWon, plus losses metric.
Custom achievement-only sticker packs (Rulership 👑, Firestorm 🔥) with new
inline-SVG art (crown-gold, seven-zip, streak-fire), unlocked by hakem_7 /
streak_10. Server GameRoom tallies hakem rounds per seat + rounds won per team;
client tallies the same for vs-computer/private games (dealId-deduped).

Forfeit (surrender): a player can request forfeit; if the teammate is a bot it
auto-confirms, otherwise the human teammate gets a confirm/decline prompt
(20s timeout). Result: forfeiting with ≥1 round won = normal loss; 0 rounds = Kot.
Wired client↔server over the hub (RequestForfeit/ConfirmForfeit/DeclineForfeit
+ "forfeit" event); offline/vs-computer ends immediately in the store. Flag
button + confirm dialogs in the table.

Online count: never shows below 50 — live service floors the real count with a
drifting believable number (mock base lowered to ~50–170).

Matchmaking: real players get a longer priority window (9s) before bots fill;
bots now occasionally react after winning a trick (humanize).

Coins: starter pack is 95,000 Toman (50k coins); packs rescaled up (server + mock).

Verified: dotnet build + tsc + next build clean; sim unlocks 57 achievements/500
matches; live server: starter=95000, a 7-hakem win unlocks hakem_7 + wins_1 with
hakemRounds/roundsWon persisted. Images rebuilt on :1500/:1505.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:47:36 +03:30

205 lines
9.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// 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()
{
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();
}
private static int Metric(string m, StatsDto st, int level) => m switch
{
"wins" => st.Wins,
"losses" => st.Losses,
"kotsFor" => st.KotsFor,
"bestWinStreak" => st.BestWinStreak,
"shutoutWins" => st.ShutoutWins,
"games" => st.Games,
"tricks" => st.Tricks,
"hakemRounds" => st.HakemRounds,
"roundsWon" => st.RoundsWon,
"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;
st.HakemRounds += s.HakemRounds;
st.RoundsWon += s.RoundsWon;
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,
};
}
}