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);
}
+38
View File
@@ -224,6 +224,44 @@ const STICKERS: Record<string, React.ReactNode> = {
<path d="M50 18 C58 34 70 38 66 56 C64 70 54 78 50 78 C46 78 34 72 34 56 C34 46 42 44 44 36 C50 42 48 50 52 52 C58 50 54 38 50 18 Z" fill="url(#sf)" />
</>
),
/* ---------------------- Persian-text stamps ------------------------- */
"kot-text": (
<>
<rect x="6" y="26" width="88" height="48" rx="10" fill="#7a0f1a" stroke="#ff6b81" strokeWidth="3" transform="rotate(-8 50 50)" />
<text x="50" y="61" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="34" fill="#ffd9de" transform="rotate(-8 50 50)">کوت!</text>
</>
),
"hokm-text": (
<>
<circle cx="50" cy="50" r="42" fill="#13314d" stroke="#d4af37" strokeWidth="3" />
<text x="50" y="62" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="30" fill="#ffe488">حکم</text>
</>
),
"damet-garm": (
<>
<rect x="8" y="30" width="84" height="40" rx="20" fill="#0d6b5e" stroke="#2dd4bf" strokeWidth="3" />
<text x="50" y="57" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="19" fill="#d8fff5">دمت گرم</text>
</>
),
barikalla: (
<>
<circle cx="50" cy="50" r="42" fill="#5a3c0a" stroke="#ffd76a" strokeWidth="3" />
<text x="50" y="58" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="20" fill="#ffe9a8">باریکلا</text>
</>
),
akhe: (
<>
<circle cx="50" cy="50" r="42" fill="#3a2a4d" stroke="#c77dff" strokeWidth="3" />
<text x="50" y="60" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="24" fill="#e7d4ff">آخه؟!</text>
</>
),
bardim: (
<>
<rect x="6" y="28" width="88" height="44" rx="10" fill="#136f3a" stroke="#7fe3a0" strokeWidth="3" transform="rotate(6 50 50)" />
<text x="50" y="59" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="26" fill="#daffe4" transform="rotate(6 50 50)">بردیم!</text>
</>
),
};
export const STICKER_IDS = Object.keys(STICKERS);
+3 -1
View File
@@ -16,6 +16,7 @@ import {
CARD_FRONTS,
TITLES,
achievementProgress,
ownedAvatarIds,
ownedCardBackIds,
ownedCardFrontIds,
} from "@/lib/online/gamification";
@@ -43,6 +44,7 @@ export function ProfileScreen() {
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
const ownedFronts = new Set(ownedCardFrontIds(profile));
const ownedBacks = new Set(ownedCardBackIds(profile));
const ownedAvatars = new Set(ownedAvatarIds(profile));
const saveName = async () => {
if (name.trim()) await updateProfile({ displayName: name.trim() });
@@ -142,7 +144,7 @@ export function ProfileScreen() {
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.chooseAvatar")}</h3>
<div className="flex flex-wrap gap-2">
{AVATARS.filter((a) => profile.ownedAvatars.includes(a.id)).map((a) => (
{AVATARS.filter((a) => ownedAvatars.has(a.id)).map((a) => (
<button
key={a.id}
onClick={() => updateProfile({ avatar: a.id })}
+84 -11
View File
@@ -2,6 +2,7 @@
// daily rewards, achievements. No side effects, no storage — unit-testable.
import {
AVATARS,
AchievementCategoryDef,
AchievementCategoryId,
AchievementDef,
@@ -131,18 +132,30 @@ export function leagueById(id: string): MatchLeague {
/* ------------------------------- XP ---------------------------------- */
/** XP required to advance from `level` to `level + 1`. */
/** Hard level cap. */
export const MAX_LEVEL = 100;
/** XP required to advance from `level` to `level + 1` — grows with level, so
* each level is harder than the last. */
export function xpNeededForLevel(level: number): number {
return 100 * level;
return 100 * level + 15 * level * level;
}
/** Higher leagues (bigger stake) grant more XP, so high-level players progress
* by playing up rather than grinding the starter league. */
export function leagueXpFactor(stake: number): number {
if (stake >= 1000) return 2;
if (stake >= 500) return 1.5;
return 1;
}
export function matchXp(summary: MatchSummary): number {
return (
const base =
40 +
(summary.won ? 80 : 0) +
summary.tricksWon * 5 +
(summary.kotFor ? 30 : 0)
);
(summary.kotFor ? 30 : 0);
return Math.round(base * leagueXpFactor(summary.stake));
}
export interface LevelProgress {
@@ -155,11 +168,16 @@ export function addXp(level: number, xpInLevel: number, gained: number): LevelPr
let lvl = level;
let xp = xpInLevel + gained;
let leveledUp = false;
while (xp >= xpNeededForLevel(lvl)) {
while (lvl < MAX_LEVEL && xp >= xpNeededForLevel(lvl)) {
xp -= xpNeededForLevel(lvl);
lvl += 1;
leveledUp = true;
}
// At the cap, don't let XP overflow the bar.
if (lvl >= MAX_LEVEL) {
lvl = MAX_LEVEL;
xp = Math.min(xp, xpNeededForLevel(MAX_LEVEL));
}
return { level: lvl, xp, leveledUp };
}
@@ -288,8 +306,16 @@ export const TITLES: TitleDef[] = [
{ id: "professional", nameFa: "حرفه‌ای", nameEn: "Professional", hintFa: "۵۰ برد", hintEn: "50 wins" },
{ id: "veteran", nameFa: "کهنه‌کار", nameEn: "Veteran", hintFa: "سطح ۳۰", hintEn: "Level 30" },
{ id: "captain", nameFa: "کاپیتان", nameEn: "Captain", hintFa: "۱۰۰ برد", hintEn: "100 wins" },
{ id: "marksman", nameFa: "کماندار", nameEn: "Marksman", hintFa: "۵۰ کُت", hintEn: "50 kots" },
{ id: "untouchable", nameFa: "شکست‌ناپذیر", nameEn: "Untouchable", hintFa: "۱۰ برد پیاپی", hintEn: "10 win streak" },
{ id: "sweeper", nameFa: "جاروکش", nameEn: "Sweeper", hintFa: "۱۰ هفت–هیچ", hintEn: "10 sweeps" },
{ id: "ruler", nameFa: "فرمانروا", nameEn: "Ruler", hintFa: "۵۰ بار حاکم", hintEn: "50× hakem" },
{ id: "champion", nameFa: "قهرمان", nameEn: "Champion", hintFa: "لیگ طلا", hintEn: "Gold league" },
{ id: "platinum_star", nameFa: "ستاره پلاتین", nameEn: "Platinum Star", hintFa: "لیگ پلاتین", hintEn: "Platinum league" },
{ id: "leader", nameFa: "فرمانده", nameEn: "Leader", hintFa: "۲۵۰ برد", hintEn: "250 wins" },
{ id: "diamond_ace", nameFa: "آس الماس", nameEn: "Diamond Ace", hintFa: "لیگ الماس", hintEn: "Diamond league" },
{ id: "immortal", nameFa: "جاودانه", nameEn: "Immortal", hintFa: "سطح ۵۰", hintEn: "Level 50" },
{ id: "the_one", nameFa: "یگانه", nameEn: "The One", hintFa: "۵۰۰ برد", hintEn: "500 wins" },
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" },
];
@@ -314,10 +340,26 @@ export function titleUnlocked(
return level >= 30;
case "captain":
return stats.wins >= 100;
case "marksman":
return stats.kotsFor >= 50;
case "untouchable":
return stats.bestWinStreak >= 10;
case "sweeper":
return (stats.shutoutWins ?? 0) >= 10;
case "ruler":
return (stats.hakemRounds ?? 0) >= 50;
case "champion":
return rating >= tierById("gold").floor;
case "platinum_star":
return rating >= tierById("platinum").floor;
case "leader":
return stats.wins >= 250;
case "diamond_ace":
return rating >= tierById("diamond").floor;
case "immortal":
return level >= 50;
case "the_one":
return stats.wins >= 500;
case "legend":
return rating >= tierById("master").floor;
default:
@@ -330,19 +372,34 @@ export function titleUnlocked(
// Card BACKS (pattern on the reverse of every card).
export const CARD_BACKS: CardBackDef[] = [
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true },
{ id: "midnight", nameFa: "نیمه‌شب", nameEn: "Midnight", c1: "#1b2540", c2: "#0a0f1f", accent: "#8aa0c8", price: 1200 },
{ id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800 },
{ id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000 },
{ id: "ruby", nameFa: اقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300 }, // earned
{ id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50 }, // earned
{ id: "jade", nameFa: شم", nameEn: "Jade", c1: "#136f63", c2: "#08221e", accent: "#7fe3c0", price: 2000 },
{ id: "onyx", nameFa: "اونیکس", nameEn: "Onyx", c1: "#26262b", c2: "#0c0c10", accent: "#b0b0c0", price: 1500 },
// earned by rank / wins — the higher the rank, the rarer the back
{ id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 0, unlockWins: 25 },
{ id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300 },
{ id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50 },
{ id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 0, unlockRating: 1500 },
{ id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 0, unlockRating: 1700 },
{ id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 0, unlockRating: 1900 },
];
// Card FRONTS (the face background/border behind the suit + rank).
export const CARD_FRONTS: CardFrontDef[] = [
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", bg1: "#fffdf7", bg2: "#f3ead2", border: "rgba(0,0,0,0.12)", price: 0, default: true },
{ id: "ivory", nameFa: "عاج", nameEn: "Ivory", bg1: "#ffffff", bg2: "#eef2f8", border: "#c9ccd6", price: 600 },
{ id: "sunset", nameFa: "غروب", nameEn: "Sunset", bg1: "#fff3e6", bg2: "#ffd9b0", border: "#e0915a", price: 1000 },
{ id: "rosegold", nameFa: "رزگلد", nameEn: "Rose Gold", bg1: "#fff1ee", bg2: "#f6d9cf", border: "#d98a72", price: 900 },
{ id: "parchment", nameFa: "پوست‌نوشت", nameEn: "Parchment", bg1: "#fbf2d8", bg2: "#efd9a3", border: "#caa84a", price: 0, unlockRating: 1300 }, // earned
{ id: "mint", nameFa: "نعنایی", nameEn: "Mint", bg1: "#f0fff8", bg2: "#d3f3e3", border: "#57c79a", price: 0, unlockWins: 50 }, // earned
{ id: "velvet", nameFa: "مخمل", nameEn: "Velvet", bg1: "#f4ecff", bg2: "#dcc9f5", border: "#9a6fd0", price: 1800 },
{ id: "onyx-face", nameFa: "شب‌رنگ", nameEn: "Onyx", bg1: "#2a2a31", bg2: "#16161c", border: "#5a5a6a", price: 1200 },
// earned by rank / wins
{ id: "parchment", nameFa: "پوست‌نوشت", nameEn: "Parchment", bg1: "#fbf2d8", bg2: "#efd9a3", border: "#caa84a", price: 0, unlockRating: 1300 },
{ id: "mint", nameFa: "نعنایی", nameEn: "Mint", bg1: "#f0fff8", bg2: "#d3f3e3", border: "#57c79a", price: 0, unlockWins: 50 },
{ id: "goldleaf", nameFa: "زرورق", nameEn: "Gold Leaf", bg1: "#fff7df", bg2: "#f2dd9b", border: "#caa53a", price: 0, unlockRating: 1500 },
{ id: "crystal", nameFa: "بلور", nameEn: "Crystal", bg1: "#eefcff", bg2: "#cdeefa", border: "#5fb6d6", price: 0, unlockRating: 1700 },
{ id: "imperial-face", nameFa: "شاهانه", nameEn: "Imperial", bg1: "#fff4cf", bg2: "#ecc873", border: "#b8862a", price: 0, unlockWins: 100 },
];
export function cardBackById(id: string): CardBackDef {
@@ -370,6 +427,19 @@ function ownedCosmeticIds(
export function ownedCardBackIds(profile: UserProfile): string[] {
return ownedCosmeticIds(CARD_BACKS, profile, profile.ownedCardBacks ?? []);
}
/** Avatars the player owns (default + rank/wins-earned + purchased). */
export function ownedAvatarIds(profile: UserProfile): string[] {
const purchased = profile.ownedAvatars ?? [];
const ids = new Set<string>();
for (const a of AVATARS) {
const earned =
(a.unlockRating != null && profile.rating >= a.unlockRating) ||
(a.unlockWins != null && profile.stats.wins >= a.unlockWins);
if (a.default || earned || purchased.includes(a.id)) ids.add(a.id);
}
return [...ids];
}
export function ownedCardFrontIds(profile: UserProfile): string[] {
return ownedCosmeticIds(CARD_FRONTS, profile, profile.ownedCardFronts ?? []);
}
@@ -417,9 +487,12 @@ export const STICKER_PACKS: StickerPackDef[] = [
{ id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700, unlockAchievement: "wins_100" },
// Earned by the "25 Kots" achievement.
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900, unlockAchievement: "kot_25" },
// Custom packs earned only via achievements.
// Persian-text stamps (کوت! / دمت گرم / باریکلا / آخه؟) — purchasable.
{ id: "persian-text", nameFa: "متن فارسی", nameEn: "Persian Text", stickers: ["kot-text", "damet-garm", "barikalla", "akhe"], price: 1100 },
// Custom packs earned only via achievements / rank.
{ id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 0, unlockAchievement: "hakem_7" },
{ id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 0, unlockAchievement: "streak_10" },
{ id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text"], price: 0, unlockRating: 1500 },
];
export function stickerPackById(id: string): StickerPackDef | undefined {
+2 -2
View File
@@ -839,12 +839,12 @@ export class MockOnlineService implements OnlineService {
}
async getShopItems(): Promise<ShopItem[]> {
const avatarItems: ShopItem[] = AVATARS.slice(2).map((a, i) => ({
const avatarItems: ShopItem[] = AVATARS.filter((a) => (a.price ?? 0) > 0).map((a) => ({
id: a.id,
kind: "avatar",
nameFa: "آواتار",
nameEn: "Avatar",
price: 500 + i * 150,
price: a.price!,
preview: a.emoji,
}));
const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({
+30 -11
View File
@@ -499,17 +499,36 @@ export interface AppNotification {
/* ------------------------------ Avatars ------------------------------ */
export const AVATARS: { id: string; emoji: string }[] = [
{ id: "a-fox", emoji: "🦊" },
{ id: "a-lion", emoji: "🦁" },
{ id: "a-owl", emoji: "🦉" },
{ id: "a-tiger", emoji: "🐯" },
{ id: "a-panda", emoji: "🐼" },
{ id: "a-eagle", emoji: "🦅" },
{ id: "a-wolf", emoji: "🐺" },
{ id: "a-cat", emoji: "🐱" },
{ id: "a-dragon", emoji: "🐲" },
{ id: "a-unicorn", emoji: "🦄" },
export interface AvatarDef {
id: string;
emoji: string;
price?: number; // >0 → purchasable in the shop
default?: boolean; // owned from the start
unlockRating?: number; // earned at this rating (better avatars = higher rank)
unlockWins?: number;
}
export const AVATARS: AvatarDef[] = [
{ id: "a-fox", emoji: "🦊", default: true },
{ id: "a-lion", emoji: "🦁", default: true },
{ id: "a-owl", emoji: "🦉", price: 400 },
{ id: "a-cat", emoji: "🐱", price: 500 },
{ id: "a-tiger", emoji: "🐯", price: 500 },
{ id: "a-panda", emoji: "🐼", price: 600 },
{ id: "a-bear", emoji: "🐻", price: 600 },
{ id: "a-eagle", emoji: "🦅", price: 700 },
{ id: "a-wolf", emoji: "🐺", price: 700 },
{ id: "a-shark", emoji: "🦈", price: 900 },
{ id: "a-dragon", emoji: "🐲", price: 1500 },
{ id: "a-unicorn", emoji: "🦄", price: 1500 },
{ id: "a-peacock", emoji: "🦚", price: 2000 },
// earned by rank / wins — the rarer faces sit behind higher ranks
{ id: "a-robot", emoji: "🤖", unlockWins: 50 },
{ id: "a-wizard", emoji: "🧙", unlockRating: 1300 },
{ id: "a-ninja", emoji: "🥷", unlockWins: 100 },
{ id: "a-king", emoji: "🤴", unlockRating: 1500 },
{ id: "a-genie", emoji: "🧞", unlockRating: 1700 },
{ id: "a-crown", emoji: "👑", unlockRating: 1900 },
];
export function avatarEmoji(id: string): string {