From 7a18bc39e634f3910d243411f084d9963299dbf5 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 21:47:38 +0330 Subject: [PATCH] Achievements overhaul: 37 achievements, page with tabs, leagues, gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/sim.ts | 5 +- server/src/Hokm.Server/Game/GameRoom.cs | 1 + .../src/Hokm.Server/Profiles/Gamification.cs | 99 +++++++--- .../src/Hokm.Server/Profiles/ProfileModels.cs | 2 + .../Hokm.Server/Profiles/ProfileService.cs | 3 +- src/app/page.tsx | 3 + .../online/PostMatchRewardsModal.tsx | 40 +++- src/components/screens/AchievementsScreen.tsx | 159 ++++++++++++++++ src/components/screens/GameScreen.tsx | 2 + src/components/screens/OnlineLobbyScreen.tsx | 83 +++++--- src/components/screens/ProfileScreen.tsx | 49 ++++- src/lib/i18n.tsx | 18 ++ src/lib/online/gamification.ts | 178 +++++++++++++----- src/lib/online/mock-service.ts | 1 + src/lib/online/types.ts | 46 +++++ src/lib/ui-store.ts | 5 +- 16 files changed, 592 insertions(+), 102 deletions(-) create mode 100644 src/components/screens/AchievementsScreen.tsx diff --git a/scripts/sim.ts b/scripts/sim.ts index aabd830..9299cd7 100644 --- a/scripts/sim.ts +++ b/scripts/sim.ts @@ -85,7 +85,7 @@ function baseProfile(): UserProfile { rating: 1000, stats: { games: 0, wins: 0, losses: 0, kotsFor: 0, kotsAgainst: 0, - tricks: 0, bestWinStreak: 0, currentWinStreak: 0, + tricks: 0, bestWinStreak: 0, currentWinStreak: 0, shutoutWins: 0, }, plan: "free", ownedAvatars: ["a-fox"], @@ -122,6 +122,7 @@ for (let i = 0; i < M; i++) { tricksWon: won ? 7 + (i % 6) : i % 7, rounds: 7, trump: "spades", + shutout: won && i % 8 === 0, }; const before = profile; const { profile: after, reward } = applyMatchResult(before, summary, 1000); @@ -148,7 +149,7 @@ for (let i = 0; i < M; i++) { { const r = applyMatchResult(baseProfile(), { ranked: false, stake: 0, won: true, kotFor: false, kotAgainst: false, - tricksWon: 7, rounds: 7, trump: null, + tricksWon: 7, rounds: 7, trump: null, shutout: false, }, 1000); assert(r.reward.ratingDelta === 0, "casual match must not change rating"); } diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs index 1ed5ec3..712d1ba 100644 --- a/server/src/Hokm.Server/Game/GameRoom.cs +++ b/server/src/Hokm.Server/Game/GameRoom.cs @@ -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(); diff --git a/server/src/Hokm.Server/Profiles/Gamification.cs b/server/src/Hokm.Server/Profiles/Gamification.cs index 9c0ceda..f47311a 100644 --- a/server/src/Hokm.Server/Profiles/Gamification.cs +++ b/server/src/Hokm.Server/Profiles/Gamification.cs @@ -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(); 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)) { diff --git a/server/src/Hokm.Server/Profiles/ProfileModels.cs b/server/src/Hokm.Server/Profiles/ProfileModels.cs index 4f3e734..beb24a2 100644 --- a/server/src/Hokm.Server/Profiles/ProfileModels.cs +++ b/server/src/Hokm.Server/Profiles/ProfileModels.cs @@ -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; } } /// Mirrors the client UserProfile (camelCase JSON). @@ -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 diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index 84b5b9f..2e4ccc9 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -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()!; diff --git a/src/app/page.tsx b/src/app/page.tsx index 64fc7f1..1459111 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,6 +11,7 @@ import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen"; import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen"; import { ShopScreen } from "@/components/screens/ShopScreen"; import { BuyCoinsScreen } from "@/components/screens/BuyCoinsScreen"; +import { AchievementsScreen } from "@/components/screens/AchievementsScreen"; import { ChatScreen } from "@/components/screens/ChatScreen"; import { NotificationsScreen } from "@/components/screens/NotificationsScreen"; import { AuthScreen } from "@/components/screens/AuthScreen"; @@ -168,6 +169,8 @@ function renderScreen(screen: string) { return ; case "buycoins": return ; + case "achievements": + return ; case "chat": return ; case "notifications": diff --git a/src/components/online/PostMatchRewardsModal.tsx b/src/components/online/PostMatchRewardsModal.tsx index 4cc4de9..4608ec5 100644 --- a/src/components/online/PostMatchRewardsModal.tsx +++ b/src/components/online/PostMatchRewardsModal.tsx @@ -2,11 +2,29 @@ import { motion } from "framer-motion"; import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useI18n } from "@/lib/i18n"; import { sound } from "@/lib/sound"; import { RewardResult } from "@/lib/online/types"; +/** Animated count-up used for the coins-won hero. */ +function CountUp({ to }: { to: number }) { + const [n, setN] = useState(0); + useEffect(() => { + let raf = 0; + const start = performance.now(); + const dur = 900; + const tick = (now: number) => { + const p = Math.min(1, (now - start) / dur); + setN(Math.round(to * (1 - Math.pow(1 - p, 3)))); // ease-out + if (p < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [to]); + return {n.toLocaleString()}; +} + export function PostMatchRewardsModal({ reward, won, @@ -52,6 +70,26 @@ export function PostMatchRewardsModal({ {won ? t("reward.win") : t("reward.lose")}

+ {/* Coins-won hero (animated count-up) */} + {won && reward.coinsDelta > 0 && ( + + + + + + + + + + )} +
{reward.ratingDelta !== 0 && ( s.profile); + const [tab, setTab] = useState("victory"); + + if (!profile) return null; + const stats = profile.stats; + + const unlockedCount = ACHIEVEMENTS.filter((a) => profile.unlocked.includes(a.id)).length; + const coinsEarned = ACHIEVEMENTS.filter((a) => profile.unlocked.includes(a.id)).reduce( + (sum, a) => sum + a.coinReward, + 0 + ); + + const list = ACHIEVEMENTS.filter((a) => a.category === tab); + + return ( + + + + {/* summary */} +
+ +
+ } + /> +
+ + {/* category tabs */} +
+ {ACHIEVEMENT_CATEGORIES.map((c) => { + const active = tab === c.id; + const done = ACHIEVEMENTS.filter( + (a) => a.category === c.id && profile.unlocked.includes(a.id) + ).length; + const total = ACHIEVEMENTS.filter((a) => a.category === c.id).length; + return ( + + ); + })} +
+ + {/* achievement list */} +
+ {list.map((a, i) => { + const prog = achievementProgress(a, stats, profile.rating, profile.level); + const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal; + const pct = Math.min(100, Math.round((prog / a.goal) * 100)); + const pack = stickerPackForAchievement(a.id); + return ( + + + {a.icon} + +
+
+ + {locale === "fa" ? a.nameFa : a.nameEn} + + {pack && ( + + + {t("achv.unlocksSticker")} + + )} +
+
+ {locale === "fa" ? a.descFa : a.descEn} +
+ {!unlocked && a.goal > 1 && ( +
+
+
+
+ + {prog}/{a.goal} + +
+ )} +
+
+ {unlocked ? ( + + ) : ( + + +{a.coinReward} + + + )} +
+ + ); + })} +
+ + ); +} + +function Stat({ + value, + label, + icon, +}: { + value: string; + label: string; + icon?: React.ReactNode; +}) { + return ( +
+
+ {icon} + {value} +
+
{label}
+
+ ); +} diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx index 64c4822..328771f 100644 --- a/src/components/screens/GameScreen.tsx +++ b/src/components/screens/GameScreen.tsx @@ -73,6 +73,8 @@ export function GameScreen() { tricksWon: tally.tricksTeam0, rounds: game.matchScore[0] + game.matchScore[1], trump: game.trump, + // shutout = you won and the opponent never scored a round (e.g. 7–0) + shutout: game.matchWinner === 0 && game.matchScore[1] === 0, }; getService() .submitMatchResult(summary) diff --git a/src/components/screens/OnlineLobbyScreen.tsx b/src/components/screens/OnlineLobbyScreen.tsx index fb98437..e6c27f6 100644 --- a/src/components/screens/OnlineLobbyScreen.tsx +++ b/src/components/screens/OnlineLobbyScreen.tsx @@ -1,24 +1,28 @@ "use client"; import { motion } from "framer-motion"; -import { Coins, Trophy, Users } from "lucide-react"; +import { Coins, Lock, Trophy, Users } from "lucide-react"; import { useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; +import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification"; import { useOnlineStore } from "@/lib/online-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { cn } from "@/lib/cn"; -const ENTRIES = [100, 500, 1000]; - export function OnlineLobbyScreen() { - const { t } = useI18n(); + const { t, locale } = useI18n(); const createRoom = useOnlineStore((s) => s.createRoom); const startMatchmaking = useOnlineStore((s) => s.startMatchmaking); const go = useUIStore((s) => s.go); - const coins = useSessionStore((s) => s.profile?.coins ?? 0); - const [entry, setEntry] = useState(100); + const profile = useSessionStore((s) => s.profile); + const coins = profile?.coins ?? 0; + const level = profile?.level ?? 1; + const [leagueId, setLeagueId] = useState(MATCH_LEAGUES[0].id); + const league = leagueById(leagueId); + const entry = league.entry; + const lockedLeague = level < league.minLevel; // Private rooms with friends are free. const onCreate = async () => { @@ -28,6 +32,7 @@ export function OnlineLobbyScreen() { // Ranked random always costs the entry (you stake it). const onRandom = async () => { + if (lockedLeague) return; if (coins < entry) { go("buycoins"); return; @@ -48,27 +53,59 @@ export function OnlineLobbyScreen() { } /> - {/* entry (only for ranked) */} + {/* league pick (only for ranked) */}
- - {t("lobby.entry")} + + {t("lobby.chooseLeague")}
-
- {ENTRIES.map((s) => ( - - ))} +
+ {MATCH_LEAGUES.map((l) => { + const locked = level < l.minLevel; + const active = l.id === leagueId; + return ( + + ); + })}
- {coins < entry && ( + {!lockedLeague && coins < entry && (

{t("lobby.needCoins")}

)}
diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index 9ce0a02..ce9d337 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -1,6 +1,6 @@ "use client"; -import { Check, Coins, Crown, Music, Pencil, Upload, Volume2 } from "lucide-react"; +import { Check, ChevronLeft, Coins, Crown, Lock, Music, Pencil, Upload, Volume2 } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { RankBadge } from "@/components/online/RankBadge"; @@ -8,6 +8,7 @@ import { XpBar } from "@/components/online/XpBar"; import { Avatar } from "@/components/online/Avatar"; import { useSessionStore } from "@/lib/session-store"; import { useSoundStore } from "@/lib/sound-store"; +import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { ACHIEVEMENTS, @@ -18,6 +19,9 @@ import { ownedCardBackIds, ownedCardFrontIds, } from "@/lib/online/gamification"; + +/** Level required before a player can upload a custom profile photo. */ +const PHOTO_UPLOAD_MIN_LEVEL = 25; import { AVATARS } from "@/lib/online/types"; import { cn } from "@/lib/cn"; @@ -26,11 +30,13 @@ export function ProfileScreen() { const profile = useSessionStore((s) => s.profile); const updateProfile = useSessionStore((s) => s.updateProfile); const upgradePlan = useSessionStore((s) => s.upgradePlan); + const go = useUIStore((st) => st.go); const fileRef = useRef(null); const [editing, setEditing] = useState(false); const [name, setName] = useState(profile?.displayName ?? ""); if (!profile) return null; + const canUpload = profile.level >= PHOTO_UPLOAD_MIN_LEVEL; const s = profile.stats; const winrate = s.games > 0 ? Math.round((s.wins / s.games) * 100) : 0; const titleDef = TITLES.find((x) => x.id === profile.title); @@ -45,7 +51,7 @@ export function ProfileScreen() { const onUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (!file) return; + if (!file || !canUpload) return; const reader = new FileReader(); reader.onload = () => updateProfile({ avatarImage: String(reader.result) }); reader.readAsDataURL(file); @@ -62,11 +68,14 @@ export function ProfileScreen() {
@@ -242,10 +251,32 @@ export function ProfileScreen() { {/* achievements */}
-

{t("profile.achievements")}

+
+

+ {t("profile.achievements")} + + {" "} + ({profile.unlocked.length}/{ACHIEVEMENTS.length}) + +

+ +
- {ACHIEVEMENTS.map((a) => { - const prog = achievementProgress(a.id, s, profile.rating); + {[...ACHIEVEMENTS] + .sort((a, b) => { + const ua = profile.unlocked.includes(a.id) ? 1 : 0; + const ub = profile.unlocked.includes(b.id) ? 1 : 0; + return ub - ua; + }) + .slice(0, 6) + .map((a) => { + const prog = achievementProgress(a, s, profile.rating, profile.level); const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal; const pct = Math.min(100, Math.round((prog / a.goal) * 100)); return ( diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 2ebd21d..50e2454 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -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", diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index e134bbe..a637e2e 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -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, 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(); 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); diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 0867154..2175c03 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -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"], diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 50311e3..b931d0b 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -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 { diff --git a/src/lib/ui-store.ts b/src/lib/ui-store.ts index 35ac767..8508e83 100644 --- a/src/lib/ui-store.ts +++ b/src/lib/ui-store.ts @@ -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 {