Achievements overhaul: 37 achievements, page with tabs, leagues, gating
Achievements (client + server mirror, metric-driven so the list is one source): - 37 achievements across 6 categories (Victories, Kot, Streaks, Levels, Ranks, Veterancy) incl. 7–0 sweeps, kot milestones (1/5/10/25/50/100), win streaks (3/5/10/15), level milestones every 5 (5..50), rank floors, games/tricks. - New AchievementsScreen with category tabs, progress bars, coin + sticker-unlock badges, and unlocked/locked states; summary header (unlocked count + coins). - Some achievements unlock sticker packs: Seven–Zip→Hokm, 25 Kots→Taunts, 100 Wins→Persian (ownedStickerPackIds now also honors profile.unlocked). - Prestige titles added: Expert, Professional, Captain, Leader (+ existing). - Tracks new stat shutoutWins; MatchSummary.shutout (7–0). Profile shows a 6-item preview + "view all" link. Leagues: 3 ranked entry tiers — Starter (100, lvl1), Pro (500, lvl10), Expert (1000, lvl20). Higher league stakes more, so wins/losses swing bigger; kot bonus now scales to the stake (40%). OnlineLobby shows league cards with level gating. Profile photo upload gated to level 25 (client button + server Update guard). Win animation: PostMatchRewardsModal now shows an animated coins-won count-up hero on a win. Verified: dotnet build + tsc + next build clean; sim unlocks 26 achievements over 500 matches; live server grants first_win/first_kot/shutout_1 and pays 2050 coins on an expert-league shutout+kot win. Images rebuilt on :1500/:1505. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,7 @@ public sealed class GameRoom : IDisposable
|
||||
KotAgainst = _tallyKot[1 - team],
|
||||
TricksWon = _tallyTricks[team],
|
||||
Rounds = rounds,
|
||||
Shutout = team == winner && State.MatchScore[1 - winner] == 0,
|
||||
};
|
||||
using var scope = _scopes.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
|
||||
|
||||
@@ -33,7 +33,8 @@ public static class Gamification
|
||||
public static int CoinDelta(MatchSummaryDto s)
|
||||
{
|
||||
if (!s.Ranked) return 0;
|
||||
int kot = s.Won && s.KotFor ? 40 : 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;
|
||||
}
|
||||
|
||||
@@ -41,45 +42,98 @@ public static class Gamification
|
||||
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);
|
||||
// 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 =
|
||||
{
|
||||
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),
|
||||
// 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, "هفت–هیچ", "Seven–Zip", "🧹"),
|
||||
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 AchProgress(string id, StatsDto st, int rating) => id switch
|
||||
private static int Metric(string m, StatsDto st, int level) => m 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),
|
||||
"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("kot_master", "استاد کُت", "Kot Master"), new("veteran", "کهنهکار", "Veteran"),
|
||||
new("champion", "قهرمان", "Champion"), new("legend", "اسطوره", "Legend"),
|
||||
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,
|
||||
"kot_master" => st.KotsFor >= 10,
|
||||
"veteran" => level >= 20,
|
||||
"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,
|
||||
};
|
||||
@@ -111,12 +165,13 @@ public static class Gamification
|
||||
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.Id, st, ratingAfter);
|
||||
int prog = AchProgress(d, st, ratingAfter, lvl.level);
|
||||
p.Achievements[d.Id] = prog;
|
||||
if (prog >= d.Goal && !p.Unlocked.Contains(d.Id))
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ public class StatsDto
|
||||
public int Tricks { get; set; }
|
||||
public int BestWinStreak { get; set; }
|
||||
public int CurrentWinStreak { get; set; }
|
||||
public int ShutoutWins { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
|
||||
@@ -59,6 +60,7 @@ public class MatchSummaryDto
|
||||
public bool KotAgainst { get; set; }
|
||||
public int TricksWon { get; set; }
|
||||
public int Rounds { get; set; }
|
||||
public bool Shutout { get; set; }
|
||||
}
|
||||
|
||||
public class AchievementUnlockDto
|
||||
|
||||
@@ -59,7 +59,8 @@ public class ProfileService
|
||||
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();
|
||||
// Custom photo upload is gated behind level 25.
|
||||
if (p.Level >= 25 && 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()!;
|
||||
|
||||
Reference in New Issue
Block a user