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:
@@ -34,6 +34,15 @@ const fa: Dict = {
|
||||
"resume.matchEnded": "بازی به پایان رسید",
|
||||
"resume.matchEndedBody": "نتیجه و جایزه را ببینید",
|
||||
|
||||
"achv.title": "دستاوردها",
|
||||
"achv.unlocked": "باز شده",
|
||||
"achv.coinsEarned": "سکه کسبشده",
|
||||
"achv.viewAll": "همه",
|
||||
"achv.unlocksSticker": "استیکر",
|
||||
"lobby.chooseLeague": "لیگ را انتخاب کنید",
|
||||
"lobby.lvl": "سطح",
|
||||
"profile.uploadLocked": "آپلود عکس از سطح ۲۵ فعال میشود",
|
||||
|
||||
"seat.you": "شما",
|
||||
"team.us": "ما",
|
||||
"team.them": "حریف",
|
||||
@@ -280,6 +289,15 @@ const en: Dict = {
|
||||
"resume.matchEnded": "Match ended",
|
||||
"resume.matchEndedBody": "See the result and reward",
|
||||
|
||||
"achv.title": "Achievements",
|
||||
"achv.unlocked": "Unlocked",
|
||||
"achv.coinsEarned": "Coins earned",
|
||||
"achv.viewAll": "All",
|
||||
"achv.unlocksSticker": "Sticker",
|
||||
"lobby.chooseLeague": "Choose a league",
|
||||
"lobby.lvl": "Lvl",
|
||||
"profile.uploadLocked": "Photo upload unlocks at level 25",
|
||||
|
||||
"seat.you": "You",
|
||||
"team.us": "Us",
|
||||
"team.them": "Them",
|
||||
|
||||
+136
-42
@@ -2,11 +2,13 @@
|
||||
// daily rewards, achievements. No side effects, no storage — unit-testable.
|
||||
|
||||
import {
|
||||
AchievementCategoryDef,
|
||||
AchievementDef,
|
||||
AchievementUnlock,
|
||||
CardBackDef,
|
||||
CardFrontDef,
|
||||
LeagueInfo,
|
||||
MatchLeague,
|
||||
MatchSummary,
|
||||
PlayerStats,
|
||||
RankTier,
|
||||
@@ -106,11 +108,25 @@ export function ratingDelta(
|
||||
export function coinDelta(summary: MatchSummary): number {
|
||||
// Free games (vs computer / private friend rooms) never touch coins.
|
||||
if (!summary.ranked) return 0;
|
||||
// Ranked: win the stake (+kot bonus), lose the stake.
|
||||
const kotBonus = summary.won && summary.kotFor ? 40 : 0;
|
||||
// Ranked: win the stake (+kot bonus scaled to the league), lose the stake.
|
||||
// Higher leagues stake more, so wins/losses swing bigger.
|
||||
const kotBonus = summary.won && summary.kotFor ? Math.round(summary.stake * 0.4) : 0;
|
||||
return (summary.won ? summary.stake : -summary.stake) + kotBonus;
|
||||
}
|
||||
|
||||
/* ----------------------------- Leagues ------------------------------- */
|
||||
|
||||
/** Ranked-matchmaking coin entry tiers (the stake you win/lose). */
|
||||
export const MATCH_LEAGUES: MatchLeague[] = [
|
||||
{ id: "starter", entry: 100, minLevel: 1, color: "#2dd4bf", icon: "🌱", nameFa: "لیگ شروع", nameEn: "Starter", descFa: "ورود ۱۰۰ سکه — مناسب تازهکارها", descEn: "100-coin entry — for newcomers" },
|
||||
{ id: "pro", entry: 500, minLevel: 10, color: "#e6b800", icon: "⚔️", nameFa: "لیگ حرفهای", nameEn: "Pro", descFa: "ورود ۵۰۰ سکه — برد و باخت بزرگتر", descEn: "500-coin entry — bigger swings" },
|
||||
{ id: "expert", entry: 1000, minLevel: 20, color: "#c77dff", icon: "👑", nameFa: "لیگ استادان", nameEn: "Expert", descFa: "ورود ۱۰۰۰ سکه — برای حرفهایها", descEn: "1000-coin entry — for the best" },
|
||||
];
|
||||
|
||||
export function leagueById(id: string): MatchLeague {
|
||||
return MATCH_LEAGUES.find((l) => l.id === id) ?? MATCH_LEAGUES[0];
|
||||
}
|
||||
|
||||
/* ------------------------------- XP ---------------------------------- */
|
||||
|
||||
/** XP required to advance from `level` to `level + 1`. */
|
||||
@@ -147,50 +163,114 @@ export function addXp(level: number, xpInLevel: number, gained: number): LevelPr
|
||||
|
||||
/* --------------------------- Achievements ---------------------------- */
|
||||
|
||||
export const ACHIEVEMENTS: AchievementDef[] = [
|
||||
{ id: "first_win", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game", icon: "🥇", goal: 1, coinReward: 100 },
|
||||
{ id: "first_kot", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "حریف را کُت کنید", descEn: "Inflict a Kot on opponents", icon: "🔥", goal: 1, coinReward: 150 },
|
||||
{ id: "wins_10", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games", icon: "🎯", goal: 10, coinReward: 300 },
|
||||
{ id: "wins_100", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید", descEn: "Win 100 games", icon: "👑", goal: 100, coinReward: 2000 },
|
||||
{ id: "streak_5", nameFa: "نوار ۵ برد", nameEn: "5 Win Streak", descFa: "۵ برد پیاپی", descEn: "Win 5 in a row", icon: "⚡", goal: 5, coinReward: 400 },
|
||||
{ id: "reach_gold", nameFa: "رسیدن به طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league", icon: "🏅", goal: 1, coinReward: 500 },
|
||||
{ id: "games_50", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games", icon: "🎮", goal: 50, coinReward: 350 },
|
||||
export const ACHIEVEMENT_CATEGORIES: AchievementCategoryDef[] = [
|
||||
{ id: "victory", nameFa: "بردها", nameEn: "Victories", icon: "🏆" },
|
||||
{ id: "kot", nameFa: "کُت", nameEn: "Kot", icon: "🔥" },
|
||||
{ id: "streak", nameFa: "نوار پیروزی", nameEn: "Streaks", icon: "⚡" },
|
||||
{ id: "level", nameFa: "سطح", nameEn: "Levels", icon: "⭐" },
|
||||
{ id: "rank", nameFa: "لیگ", nameEn: "Ranks", icon: "🏅" },
|
||||
{ id: "veteran", nameFa: "کارنامه", nameEn: "Veterancy", icon: "🎮" },
|
||||
];
|
||||
|
||||
/** Current raw progress value for an achievement from stats + rating. */
|
||||
export function achievementProgress(
|
||||
id: string,
|
||||
stats: PlayerStats,
|
||||
rating: number
|
||||
): number {
|
||||
switch (id) {
|
||||
case "first_win":
|
||||
return Math.min(1, stats.wins);
|
||||
case "first_kot":
|
||||
return Math.min(1, stats.kotsFor);
|
||||
case "wins_10":
|
||||
return Math.min(10, stats.wins);
|
||||
case "wins_100":
|
||||
return Math.min(100, stats.wins);
|
||||
case "streak_5":
|
||||
return Math.min(5, stats.bestWinStreak);
|
||||
case "reach_gold":
|
||||
return rating >= tierById("gold").floor ? 1 : 0;
|
||||
case "games_50":
|
||||
return Math.min(50, stats.games);
|
||||
default:
|
||||
return 0;
|
||||
export const ACHIEVEMENTS: AchievementDef[] = [
|
||||
// ---- Victories (wins + shutouts) ----
|
||||
{ id: "first_win", category: "victory", metric: "wins", goal: 1, coinReward: 100, icon: "🥇", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game" },
|
||||
{ id: "wins_10", category: "victory", metric: "wins", goal: 10, coinReward: 300, icon: "🎯", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games" },
|
||||
{ id: "wins_25", category: "victory", metric: "wins", goal: 25, coinReward: 600, icon: "🏅", nameFa: "۲۵ برد", nameEn: "25 Wins", descFa: "۲۵ بازی ببرید", descEn: "Win 25 games" },
|
||||
{ id: "wins_50", category: "victory", metric: "wins", goal: 50, coinReward: 1000, icon: "🏆", nameFa: "۵۰ برد", nameEn: "50 Wins", descFa: "۵۰ بازی ببرید", descEn: "Win 50 games" },
|
||||
{ id: "wins_100", category: "victory", metric: "wins", goal: 100, coinReward: 2000, icon: "👑", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید (پک استیکر ایرانی)", descEn: "Win 100 games (unlocks Persian stickers)" },
|
||||
{ id: "wins_250", category: "victory", metric: "wins", goal: 250, coinReward: 4000, icon: "💎", nameFa: "۲۵۰ برد", nameEn: "250 Wins", descFa: "۲۵۰ بازی ببرید", descEn: "Win 250 games" },
|
||||
{ id: "wins_500", category: "victory", metric: "wins", goal: 500, coinReward: 8000, icon: "🌟", nameFa: "۵۰۰ برد", nameEn: "500 Wins", descFa: "۵۰۰ بازی ببرید", descEn: "Win 500 games" },
|
||||
{ id: "shutout_1", category: "victory", metric: "shutoutWins", goal: 1, coinReward: 400, icon: "🧹", nameFa: "هفت–هیچ", nameEn: "Seven–Zip", descFa: "بازی را ۷–۰ ببرید (پک استیکر حکم)", descEn: "Win a match 7–0 (unlocks Hokm stickers)" },
|
||||
{ id: "shutout_5", category: "victory", metric: "shutoutWins", goal: 5, coinReward: 900, icon: "🧨", nameFa: "۵ بار هفت–هیچ", nameEn: "5× Sweep", descFa: "۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 5 times" },
|
||||
{ id: "shutout_25", category: "victory", metric: "shutoutWins", goal: 25, coinReward: 3000, icon: "☄️", nameFa: "۲۵ بار هفت–هیچ", nameEn: "25× Sweep", descFa: "۲۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 25 times" },
|
||||
|
||||
// ---- Kot ----
|
||||
{ id: "first_kot", category: "kot", metric: "kotsFor", goal: 1, coinReward: 150, icon: "🔥", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "یک بار حریف را کُت کنید", descEn: "Inflict a Kot once" },
|
||||
{ id: "kot_5", category: "kot", metric: "kotsFor", goal: 5, coinReward: 300, icon: "🌶️", nameFa: "۵ کُت", nameEn: "5 Kots", descFa: "۵ بار حریف را کُت کنید", descEn: "Inflict 5 Kots" },
|
||||
{ id: "kot_10", category: "kot", metric: "kotsFor", goal: 10, coinReward: 500, icon: "🔥", nameFa: "۱۰ کُت", nameEn: "10 Kots", descFa: "۱۰ بار حریف را کُت کنید", descEn: "Inflict 10 Kots" },
|
||||
{ id: "kot_25", category: "kot", metric: "kotsFor", goal: 25, coinReward: 1200, icon: "💥", nameFa: "۲۵ کُت", nameEn: "25 Kots", descFa: "۲۵ بار حریف را کُت کنید (پک استیکر طعنه)", descEn: "Inflict 25 Kots (unlocks Taunt stickers)" },
|
||||
{ id: "kot_50", category: "kot", metric: "kotsFor", goal: 50, coinReward: 2500, icon: "⚡", nameFa: "۵۰ کُت", nameEn: "50 Kots", descFa: "۵۰ بار حریف را کُت کنید", descEn: "Inflict 50 Kots" },
|
||||
{ id: "kot_100", category: "kot", metric: "kotsFor", goal: 100, coinReward: 5000, icon: "👹", nameFa: "۱۰۰ کُت", nameEn: "100 Kots", descFa: "۱۰۰ بار حریف را کُت کنید", descEn: "Inflict 100 Kots" },
|
||||
|
||||
// ---- Streaks ----
|
||||
{ id: "streak_3", category: "streak", metric: "bestWinStreak", goal: 3, coinReward: 200, icon: "➡️", nameFa: "۳ برد پیاپی", nameEn: "3 Win Streak", descFa: "۳ بازی پشت سر هم ببرید", descEn: "Win 3 games in a row" },
|
||||
{ id: "streak_5", category: "streak", metric: "bestWinStreak", goal: 5, coinReward: 400, icon: "⚡", nameFa: "۵ برد پیاپی", nameEn: "5 Win Streak", descFa: "۵ بازی پشت سر هم ببرید", descEn: "Win 5 games in a row" },
|
||||
{ id: "streak_10", category: "streak", metric: "bestWinStreak", goal: 10, coinReward: 1000, icon: "🌊", nameFa: "۱۰ برد پیاپی", nameEn: "10 Win Streak", descFa: "۱۰ بازی پشت سر هم ببرید", descEn: "Win 10 games in a row" },
|
||||
{ id: "streak_15", category: "streak", metric: "bestWinStreak", goal: 15, coinReward: 2000, icon: "🚀", nameFa: "۱۵ برد پیاپی", nameEn: "15 Win Streak", descFa: "۱۵ بازی پشت سر هم ببرید", descEn: "Win 15 games in a row" },
|
||||
|
||||
// ---- Levels (every 5) ----
|
||||
{ id: "level_5", category: "level", metric: "level", goal: 5, coinReward: 150, icon: "⭐", nameFa: "سطح ۵", nameEn: "Level 5", descFa: "به سطح ۵ برسید", descEn: "Reach level 5" },
|
||||
{ id: "level_10", category: "level", metric: "level", goal: 10, coinReward: 300, icon: "🌟", nameFa: "سطح ۱۰", nameEn: "Level 10", descFa: "به سطح ۱۰ برسید", descEn: "Reach level 10" },
|
||||
{ id: "level_15", category: "level", metric: "level", goal: 15, coinReward: 500, icon: "✨", nameFa: "سطح ۱۵", nameEn: "Level 15", descFa: "به سطح ۱۵ برسید", descEn: "Reach level 15" },
|
||||
{ id: "level_20", category: "level", metric: "level", goal: 20, coinReward: 800, icon: "💫", nameFa: "سطح ۲۰", nameEn: "Level 20", descFa: "به سطح ۲۰ برسید", descEn: "Reach level 20" },
|
||||
{ id: "level_25", category: "level", metric: "level", goal: 25, coinReward: 1200, icon: "🔆", nameFa: "سطح ۲۵", nameEn: "Level 25", descFa: "به سطح ۲۵ برسید (آپلود عکس باز میشود)", descEn: "Reach level 25 (unlocks photo upload)" },
|
||||
{ id: "level_30", category: "level", metric: "level", goal: 30, coinReward: 1600, icon: "🎖️", nameFa: "سطح ۳۰", nameEn: "Level 30", descFa: "به سطح ۳۰ برسید", descEn: "Reach level 30" },
|
||||
{ id: "level_40", category: "level", metric: "level", goal: 40, coinReward: 2500, icon: "🏵️", nameFa: "سطح ۴۰", nameEn: "Level 40", descFa: "به سطح ۴۰ برسید", descEn: "Reach level 40" },
|
||||
{ id: "level_50", category: "level", metric: "level", goal: 50, coinReward: 4000, icon: "🌠", nameFa: "سطح ۵۰", nameEn: "Level 50", descFa: "به سطح ۵۰ برسید", descEn: "Reach level 50" },
|
||||
|
||||
// ---- Ranks ----
|
||||
{ id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 200, icon: "🥈", nameFa: "لیگ نقره", nameEn: "Reach Silver", descFa: "به لیگ نقره برسید", descEn: "Reach the Silver league" },
|
||||
{ id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 500, icon: "🥇", nameFa: "لیگ طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league" },
|
||||
{ id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 1000, icon: "🛡️", nameFa: "لیگ پلاتین", nameEn: "Reach Platinum", descFa: "به لیگ پلاتین برسید", descEn: "Reach the Platinum league" },
|
||||
{ id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 2000, icon: "💠", nameFa: "لیگ الماس", nameEn: "Reach Diamond", descFa: "به لیگ الماس برسید", descEn: "Reach the Diamond league" },
|
||||
{ id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 4000, icon: "👑", nameFa: "لیگ استاد", nameEn: "Reach Master", descFa: "به لیگ استاد برسید", descEn: "Reach the Master league" },
|
||||
|
||||
// ---- Veterancy (games + tricks) ----
|
||||
{ id: "games_10", category: "veteran", metric: "games", goal: 10, coinReward: 150, icon: "🎮", nameFa: "۱۰ بازی", nameEn: "10 Games", descFa: "۱۰ بازی انجام دهید", descEn: "Play 10 games" },
|
||||
{ id: "games_50", category: "veteran", metric: "games", goal: 50, coinReward: 350, icon: "🕹️", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games" },
|
||||
{ id: "games_200", category: "veteran", metric: "games", goal: 200, coinReward: 1200, icon: "🎲", nameFa: "۲۰۰ بازی", nameEn: "200 Games", descFa: "۲۰۰ بازی انجام دهید", descEn: "Play 200 games" },
|
||||
{ id: "games_500", category: "veteran", metric: "games", goal: 500, coinReward: 3000, icon: "🃏", nameFa: "۵۰۰ بازی", nameEn: "500 Games", descFa: "۵۰۰ بازی انجام دهید", descEn: "Play 500 games" },
|
||||
{ id: "games_1000", category: "veteran", metric: "games", goal: 1000, coinReward: 7000, icon: "♾️", nameFa: "۱۰۰۰ بازی", nameEn: "1000 Games", descFa: "۱۰۰۰ بازی انجام دهید", descEn: "Play 1000 games" },
|
||||
{ id: "tricks_100", category: "veteran", metric: "tricks", goal: 100, coinReward: 300, icon: "🎴", nameFa: "۱۰۰ دست", nameEn: "100 Tricks", descFa: "۱۰۰ دست ببرید", descEn: "Win 100 tricks" },
|
||||
{ id: "tricks_1000", category: "veteran", metric: "tricks", goal: 1000, coinReward: 2000, icon: "🗂️", nameFa: "۱۰۰۰ دست", nameEn: "1000 Tricks", descFa: "۱۰۰۰ دست ببرید", descEn: "Win 1000 tricks" },
|
||||
];
|
||||
|
||||
function metricValue(metric: NonNullable<AchievementDef["metric"]>, stats: PlayerStats, level: number): number {
|
||||
switch (metric) {
|
||||
case "wins": return stats.wins;
|
||||
case "kotsFor": return stats.kotsFor;
|
||||
case "bestWinStreak": return stats.bestWinStreak;
|
||||
case "shutoutWins": return stats.shutoutWins ?? 0;
|
||||
case "games": return stats.games;
|
||||
case "tricks": return stats.tricks;
|
||||
case "level": return level;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current raw progress value (0..goal) for an achievement. */
|
||||
export function achievementProgress(
|
||||
def: AchievementDef,
|
||||
stats: PlayerStats,
|
||||
rating: number,
|
||||
level: number
|
||||
): number {
|
||||
if (def.ratingFloor != null) return rating >= def.ratingFloor ? def.goal : 0;
|
||||
if (!def.metric) return 0;
|
||||
return Math.min(def.goal, metricValue(def.metric, stats, level));
|
||||
}
|
||||
|
||||
export function achievementById(id: string): AchievementDef | undefined {
|
||||
return ACHIEVEMENTS.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
/** The sticker pack (if any) that unlocking this achievement grants. */
|
||||
export function stickerPackForAchievement(achId: string): StickerPackDef | undefined {
|
||||
return STICKER_PACKS.find((p) => p.unlockAchievement === achId);
|
||||
}
|
||||
|
||||
/* ------------------------------ Titles ------------------------------- */
|
||||
|
||||
export const TITLES: TitleDef[] = [
|
||||
{ id: "novice", nameFa: "تازهکار", nameEn: "Novice", hintFa: "پیشفرض", hintEn: "Default" },
|
||||
{ id: "winner", nameFa: "برنده", nameEn: "Winner", hintFa: "۱۰ برد", hintEn: "10 wins" },
|
||||
{ id: "kot_master", nameFa: "استاد کُت", nameEn: "Kot Master", hintFa: "۱۰ کُت", hintEn: "10 kots" },
|
||||
{ id: "veteran", nameFa: "کهنهکار", nameEn: "Veteran", hintFa: "سطح ۲۰", hintEn: "Level 20" },
|
||||
{ id: "expert", nameFa: "خبره", nameEn: "Expert", hintFa: "سطح ۲۵", hintEn: "Level 25" },
|
||||
{ id: "kot_master", nameFa: "استاد کُت", nameEn: "Kot Master", hintFa: "۲۵ کُت", hintEn: "25 kots" },
|
||||
{ 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: "champion", nameFa: "قهرمان", nameEn: "Champion", hintFa: "لیگ طلا", hintEn: "Gold league" },
|
||||
{ id: "leader", nameFa: "فرمانده", nameEn: "Leader", hintFa: "۲۵۰ برد", hintEn: "250 wins" },
|
||||
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" },
|
||||
];
|
||||
|
||||
@@ -205,12 +285,20 @@ export function titleUnlocked(
|
||||
return true;
|
||||
case "winner":
|
||||
return stats.wins >= 10;
|
||||
case "expert":
|
||||
return level >= 25;
|
||||
case "kot_master":
|
||||
return stats.kotsFor >= 10;
|
||||
return stats.kotsFor >= 25;
|
||||
case "professional":
|
||||
return stats.wins >= 50;
|
||||
case "veteran":
|
||||
return level >= 20;
|
||||
return level >= 30;
|
||||
case "captain":
|
||||
return stats.wins >= 100;
|
||||
case "champion":
|
||||
return rating >= tierById("gold").floor;
|
||||
case "leader":
|
||||
return stats.wins >= 250;
|
||||
case "legend":
|
||||
return rating >= tierById("master").floor;
|
||||
default:
|
||||
@@ -304,9 +392,12 @@ export function ownedReactions(profile: UserProfile): string[] {
|
||||
|
||||
export const STICKER_PACKS: StickerPackDef[] = [
|
||||
{ id: "faces", nameFa: "شکلکها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true },
|
||||
{ id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 0, unlockRating: 1300 },
|
||||
{ id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700 },
|
||||
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900 },
|
||||
// Earned by the "Seven–Zip" (7–0 sweep) achievement.
|
||||
{ id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 0, unlockAchievement: "shutout_1" },
|
||||
// Earned by the "100 Wins" achievement.
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
export function stickerPackById(id: string): StickerPackDef | undefined {
|
||||
@@ -315,11 +406,13 @@ export function stickerPackById(id: string): StickerPackDef | undefined {
|
||||
|
||||
export function ownedStickerPackIds(profile: UserProfile): string[] {
|
||||
const purchased = profile.ownedStickerPacks ?? [];
|
||||
const unlocked = profile.unlocked ?? [];
|
||||
const ids = new Set<string>();
|
||||
for (const p of STICKER_PACKS) {
|
||||
const earned =
|
||||
(p.unlockRating != null && profile.rating >= p.unlockRating) ||
|
||||
(p.unlockWins != null && profile.stats.wins >= p.unlockWins);
|
||||
(p.unlockWins != null && profile.stats.wins >= p.unlockWins) ||
|
||||
(p.unlockAchievement != null && unlocked.includes(p.unlockAchievement));
|
||||
if (p.default || earned || purchased.includes(p.id)) ids.add(p.id);
|
||||
}
|
||||
return [...ids];
|
||||
@@ -346,6 +439,7 @@ function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
|
||||
tricks: stats.tricks + summary.tricksWon,
|
||||
currentWinStreak,
|
||||
bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak),
|
||||
shutoutWins: (stats.shutoutWins ?? 0) + (summary.won && summary.shutout ? 1 : 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -377,7 +471,7 @@ export function applyMatchResult(
|
||||
const newAchievements: AchievementUnlock[] = [];
|
||||
let achievementCoins = 0;
|
||||
for (const def of ACHIEVEMENTS) {
|
||||
const prog = achievementProgress(def.id, stats, ratingAfter);
|
||||
const prog = achievementProgress(def, stats, ratingAfter, lvl.level);
|
||||
achievements[def.id] = prog;
|
||||
if (prog >= def.goal && !unlocked.includes(def.id)) {
|
||||
unlocked.push(def.id);
|
||||
|
||||
@@ -117,6 +117,7 @@ function defaultProfile(session: AuthSession): UserProfile {
|
||||
tricks: 0,
|
||||
bestWinStreak: 0,
|
||||
currentWinStreak: 0,
|
||||
shutoutWins: 0,
|
||||
},
|
||||
ownedAvatars: [AVATARS[0].id, AVATARS[1].id],
|
||||
ownedCardFronts: ["classic"],
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface PlayerStats {
|
||||
tricks: number;
|
||||
bestWinStreak: number;
|
||||
currentWinStreak: number;
|
||||
shutoutWins: number; // matches won with the opponent on 0 rounds (e.g. 7–0)
|
||||
}
|
||||
|
||||
export type PlanId = "free" | "pro";
|
||||
@@ -99,8 +100,27 @@ export interface LeagueInfo {
|
||||
|
||||
/* --------------------------- Achievements ---------------------------- */
|
||||
|
||||
/** The cumulative stat an achievement tracks toward its goal. */
|
||||
export type AchievementMetric =
|
||||
| "wins"
|
||||
| "kotsFor"
|
||||
| "bestWinStreak"
|
||||
| "shutoutWins"
|
||||
| "games"
|
||||
| "tricks"
|
||||
| "level";
|
||||
|
||||
export type AchievementCategoryId =
|
||||
| "victory"
|
||||
| "kot"
|
||||
| "streak"
|
||||
| "level"
|
||||
| "rank"
|
||||
| "veteran";
|
||||
|
||||
export interface AchievementDef {
|
||||
id: string;
|
||||
category: AchievementCategoryId;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descFa: string;
|
||||
@@ -108,6 +128,17 @@ export interface AchievementDef {
|
||||
icon: string; // emoji or lucide name
|
||||
goal: number; // progress needed to unlock
|
||||
coinReward: number;
|
||||
/** which stat drives progress (omit for rating/rank achievements). */
|
||||
metric?: AchievementMetric;
|
||||
/** rank achievements unlock at this rating floor (goal is 1). */
|
||||
ratingFloor?: number;
|
||||
}
|
||||
|
||||
export interface AchievementCategoryDef {
|
||||
id: AchievementCategoryId;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface AchievementView extends AchievementDef {
|
||||
@@ -115,6 +146,19 @@ export interface AchievementView extends AchievementDef {
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
/** A ranked-matchmaking league: a fixed coin entry/stake tier. */
|
||||
export interface MatchLeague {
|
||||
id: string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descFa: string;
|
||||
descEn: string;
|
||||
entry: number; // coin entry == stake (win it / lose it)
|
||||
color: string;
|
||||
icon: string;
|
||||
minLevel: number; // level required to enter
|
||||
}
|
||||
|
||||
/* ----------------------- Titles & card styles ------------------------ */
|
||||
|
||||
export interface TitleDef {
|
||||
@@ -174,6 +218,7 @@ export interface StickerPackDef {
|
||||
default?: boolean;
|
||||
unlockRating?: number;
|
||||
unlockWins?: number;
|
||||
unlockAchievement?: string; // earned when this achievement id is unlocked
|
||||
}
|
||||
|
||||
/* ------------------------------ Friends ------------------------------ */
|
||||
@@ -264,6 +309,7 @@ export interface MatchSummary {
|
||||
tricksWon: number; // your team's total tricks across the match
|
||||
rounds: number;
|
||||
trump: Suit | null;
|
||||
shutout: boolean; // won with the opponent on 0 rounds (e.g. 7–0)
|
||||
}
|
||||
|
||||
export interface AchievementUnlock {
|
||||
|
||||
+3
-2
@@ -13,18 +13,19 @@ export type Screen =
|
||||
| "leaderboard"
|
||||
| "shop"
|
||||
| "buycoins"
|
||||
| "achievements"
|
||||
| "chat"
|
||||
| "notifications"
|
||||
| "game"; // the table (used for both ai + online)
|
||||
|
||||
const ALL_SCREENS: Screen[] = [
|
||||
"home", "auth", "profile", "friends", "online",
|
||||
"room", "matchmaking", "leaderboard", "shop", "buycoins", "chat", "notifications", "game",
|
||||
"room", "matchmaking", "leaderboard", "shop", "buycoins", "achievements", "chat", "notifications", "game",
|
||||
];
|
||||
|
||||
/** Screens safe to restore from a URL on a cold load (no transient state needed). */
|
||||
export const STATIC_SCREENS: Screen[] = [
|
||||
"home", "auth", "profile", "friends", "online", "leaderboard", "shop", "buycoins", "notifications",
|
||||
"home", "auth", "profile", "friends", "online", "leaderboard", "shop", "buycoins", "achievements", "notifications",
|
||||
];
|
||||
|
||||
export function screenFromHash(): Screen {
|
||||
|
||||
Reference in New Issue
Block a user