More cosmetics (rank-gated) + steeper level curve capped at 100
CI/CD / CI - API (dotnet build + engine sim) (push) Failing after 1m40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m20s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped

Cosmetics — many new variants, the rarer ones gated behind higher ranks:
- Card backs: +midnight/jade/onyx (buy) + crimson/aurora/obsidian/imperial
  (earned by wins/rating up to Master). Card fronts: +sunset/velvet/onyx (buy)
  + goldleaf/crystal/imperial (earned).
- Titles: +marksman, untouchable, sweeper, ruler, platinum_star, diamond_ace,
  immortal, the_one (gated by kots/streak/shutouts/hakem/rating/level/wins),
  mirrored on the server so live games grant them.
- Avatars: list expanded + rank/wins-earned tier (robot/wizard/ninja/king/
  genie/crown) via new ownedAvatarIds(); profile picker shows earned ones,
  shop sells the priced ones.
- Stickers: new Persian-text stamp pack (کوت! / دمت گرم / باریکلا / آخه؟) plus a
  rank-earned Victory pack (بردیم!/حکم) — new inline-SVG art.

Leveling: XP per level now grows (100*l + 15*l²) so each level is harder; higher
leagues grant more XP (×1.5 at 500 stake, ×2 at 1000) so you progress by playing
up. Hard cap at level 100. Mirrored in server Gamification (XpForLevel/MatchXp/
AddXp). Sim now tops out lower (level 20 vs 35 over 500 matches) as intended.

Verified: tsc + next build + dotnet build clean; sim passes; images rebuilt :1500/:1505.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 23:43:21 +03:30
parent dfb1deee8c
commit 4199a82c9d
6 changed files with 181 additions and 31 deletions
@@ -38,9 +38,14 @@ public static class Gamification
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);
public const int MaxLevel = 100;
public static int XpForLevel(int level) => 100 * level + 15 * level * level;
private static double LeagueXpFactor(int stake) => stake >= 1000 ? 2.0 : stake >= 500 ? 1.5 : 1.0;
public static int MatchXp(MatchSummaryDto s)
{
int b = 40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
return (int)Math.Round(b * LeagueXpFactor(s.Stake));
}
// 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);
@@ -102,8 +107,12 @@ public static class Gamification
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"),
new("captain", "کاپیتان", "Captain"), new("marksman", "کماندار", "Marksman"),
new("untouchable", "شکست‌ناپذیر", "Untouchable"), new("sweeper", "جاروکش", "Sweeper"),
new("ruler", "فرمانروا", "Ruler"), new("champion", "قهرمان", "Champion"),
new("platinum_star", "ستاره پلاتین", "Platinum Star"), new("leader", "فرمانده", "Leader"),
new("diamond_ace", "آس الماس", "Diamond Ace"), new("immortal", "جاودانه", "Immortal"),
new("the_one", "یگانه", "The One"), new("legend", "اسطوره", "Legend"),
};
private static bool TitleUnlocked(string id, StatsDto st, int rating, int level) => id switch
@@ -115,8 +124,16 @@ public static class Gamification
"professional" => st.Wins >= 50,
"veteran" => level >= 30,
"captain" => st.Wins >= 100,
"marksman" => st.KotsFor >= 50,
"untouchable" => st.BestWinStreak >= 10,
"sweeper" => st.ShutoutWins >= 10,
"ruler" => st.HakemRounds >= 50,
"champion" => rating >= 1300,
"platinum_star" => rating >= 1500,
"leader" => st.Wins >= 250,
"diamond_ace" => rating >= 1700,
"immortal" => level >= 50,
"the_one" => st.Wins >= 500,
"legend" => rating >= 1900,
_ => false,
};
@@ -125,7 +142,8 @@ public static class Gamification
{
bool up = false;
xp += gain;
while (xp >= XpForLevel(level)) { xp -= XpForLevel(level); level++; up = true; }
while (level < MaxLevel && xp >= XpForLevel(level)) { xp -= XpForLevel(level); level++; up = true; }
if (level >= MaxLevel) { level = MaxLevel; xp = Math.Min(xp, XpForLevel(MaxLevel)); }
return (level, xp, up);
}