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:
+3
-2
@@ -85,7 +85,7 @@ function baseProfile(): UserProfile {
|
|||||||
rating: 1000,
|
rating: 1000,
|
||||||
stats: {
|
stats: {
|
||||||
games: 0, wins: 0, losses: 0, kotsFor: 0, kotsAgainst: 0,
|
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",
|
plan: "free",
|
||||||
ownedAvatars: ["a-fox"],
|
ownedAvatars: ["a-fox"],
|
||||||
@@ -122,6 +122,7 @@ for (let i = 0; i < M; i++) {
|
|||||||
tricksWon: won ? 7 + (i % 6) : i % 7,
|
tricksWon: won ? 7 + (i % 6) : i % 7,
|
||||||
rounds: 7,
|
rounds: 7,
|
||||||
trump: "spades",
|
trump: "spades",
|
||||||
|
shutout: won && i % 8 === 0,
|
||||||
};
|
};
|
||||||
const before = profile;
|
const before = profile;
|
||||||
const { profile: after, reward } = applyMatchResult(before, summary, 1000);
|
const { profile: after, reward } = applyMatchResult(before, summary, 1000);
|
||||||
@@ -148,7 +149,7 @@ for (let i = 0; i < M; i++) {
|
|||||||
{
|
{
|
||||||
const r = applyMatchResult(baseProfile(), {
|
const r = applyMatchResult(baseProfile(), {
|
||||||
ranked: false, stake: 0, won: true, kotFor: false, kotAgainst: false,
|
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);
|
}, 1000);
|
||||||
assert(r.reward.ratingDelta === 0, "casual match must not change rating");
|
assert(r.reward.ratingDelta === 0, "casual match must not change rating");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ public sealed class GameRoom : IDisposable
|
|||||||
KotAgainst = _tallyKot[1 - team],
|
KotAgainst = _tallyKot[1 - team],
|
||||||
TricksWon = _tallyTricks[team],
|
TricksWon = _tallyTricks[team],
|
||||||
Rounds = rounds,
|
Rounds = rounds,
|
||||||
|
Shutout = team == winner && State.MatchScore[1 - winner] == 0,
|
||||||
};
|
};
|
||||||
using var scope = _scopes.CreateScope();
|
using var scope = _scopes.CreateScope();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
|
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public static class Gamification
|
|||||||
public static int CoinDelta(MatchSummaryDto s)
|
public static int CoinDelta(MatchSummaryDto s)
|
||||||
{
|
{
|
||||||
if (!s.Ranked) return 0;
|
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;
|
return (s.Won ? s.Stake : -s.Stake) + kot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,45 +42,98 @@ public static class Gamification
|
|||||||
public static int MatchXp(MatchSummaryDto s) =>
|
public static int MatchXp(MatchSummaryDto s) =>
|
||||||
40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
|
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 =
|
private static readonly AchDef[] Achs =
|
||||||
{
|
{
|
||||||
new("first_win", "اولین برد", "First Win", "🥇", 1, 100),
|
// victories
|
||||||
new("first_kot", "اولین کُت", "First Kot", "🔥", 1, 150),
|
new("first_win", "wins", 0, 1, 100, "اولین برد", "First Win", "🥇"),
|
||||||
new("wins_10", "۱۰ برد", "10 Wins", "🎯", 10, 300),
|
new("wins_10", "wins", 0, 10, 300, "۱۰ برد", "10 Wins", "🎯"),
|
||||||
new("wins_100", "۱۰۰ برد", "100 Wins", "👑", 100, 2000),
|
new("wins_25", "wins", 0, 25, 600, "۲۵ برد", "25 Wins", "🏅"),
|
||||||
new("streak_5", "نوار ۵ برد", "5 Win Streak", "⚡", 5, 400),
|
new("wins_50", "wins", 0, 50, 1000, "۵۰ برد", "50 Wins", "🏆"),
|
||||||
new("reach_gold", "رسیدن به طلا", "Reach Gold", "🏅", 1, 500),
|
new("wins_100", "wins", 0, 100, 2000, "۱۰۰ برد", "100 Wins", "👑"),
|
||||||
new("games_50", "۵۰ بازی", "50 Games", "🎮", 50, 350),
|
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),
|
"wins" => st.Wins,
|
||||||
"first_kot" => Math.Min(1, st.KotsFor),
|
"kotsFor" => st.KotsFor,
|
||||||
"wins_10" => Math.Min(10, st.Wins),
|
"bestWinStreak" => st.BestWinStreak,
|
||||||
"wins_100" => Math.Min(100, st.Wins),
|
"shutoutWins" => st.ShutoutWins,
|
||||||
"streak_5" => Math.Min(5, st.BestWinStreak),
|
"games" => st.Games,
|
||||||
"reach_gold" => rating >= 1300 ? 1 : 0,
|
"tricks" => st.Tricks,
|
||||||
"games_50" => Math.Min(50, st.Games),
|
"level" => level,
|
||||||
_ => 0,
|
_ => 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 record TitleDef(string Id, string NameFa, string NameEn);
|
||||||
private static readonly TitleDef[] Titles =
|
private static readonly TitleDef[] Titles =
|
||||||
{
|
{
|
||||||
new("novice", "تازهکار", "Novice"), new("winner", "برنده", "Winner"),
|
new("novice", "تازهکار", "Novice"), new("winner", "برنده", "Winner"),
|
||||||
new("kot_master", "استاد کُت", "Kot Master"), new("veteran", "کهنهکار", "Veteran"),
|
new("expert", "خبره", "Expert"), new("kot_master", "استاد کُت", "Kot Master"),
|
||||||
new("champion", "قهرمان", "Champion"), new("legend", "اسطوره", "Legend"),
|
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
|
private static bool TitleUnlocked(string id, StatsDto st, int rating, int level) => id switch
|
||||||
{
|
{
|
||||||
"novice" => true,
|
"novice" => true,
|
||||||
"winner" => st.Wins >= 10,
|
"winner" => st.Wins >= 10,
|
||||||
"kot_master" => st.KotsFor >= 10,
|
"expert" => level >= 25,
|
||||||
"veteran" => level >= 20,
|
"kot_master" => st.KotsFor >= 25,
|
||||||
|
"professional" => st.Wins >= 50,
|
||||||
|
"veteran" => level >= 30,
|
||||||
|
"captain" => st.Wins >= 100,
|
||||||
"champion" => rating >= 1300,
|
"champion" => rating >= 1300,
|
||||||
|
"leader" => st.Wins >= 250,
|
||||||
"legend" => rating >= 1900,
|
"legend" => rating >= 1900,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -111,12 +165,13 @@ public static class Gamification
|
|||||||
st.Tricks += s.TricksWon;
|
st.Tricks += s.TricksWon;
|
||||||
st.CurrentWinStreak = cur;
|
st.CurrentWinStreak = cur;
|
||||||
st.BestWinStreak = Math.Max(st.BestWinStreak, cur);
|
st.BestWinStreak = Math.Max(st.BestWinStreak, cur);
|
||||||
|
st.ShutoutWins += s.Won && s.Shutout ? 1 : 0;
|
||||||
|
|
||||||
var newAch = new List<AchievementUnlockDto>();
|
var newAch = new List<AchievementUnlockDto>();
|
||||||
int achCoins = 0;
|
int achCoins = 0;
|
||||||
foreach (var d in Achs)
|
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;
|
p.Achievements[d.Id] = prog;
|
||||||
if (prog >= d.Goal && !p.Unlocked.Contains(d.Id))
|
if (prog >= d.Goal && !p.Unlocked.Contains(d.Id))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class StatsDto
|
|||||||
public int Tricks { get; set; }
|
public int Tricks { get; set; }
|
||||||
public int BestWinStreak { get; set; }
|
public int BestWinStreak { get; set; }
|
||||||
public int CurrentWinStreak { get; set; }
|
public int CurrentWinStreak { get; set; }
|
||||||
|
public int ShutoutWins { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
|
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
|
||||||
@@ -59,6 +60,7 @@ public class MatchSummaryDto
|
|||||||
public bool KotAgainst { get; set; }
|
public bool KotAgainst { get; set; }
|
||||||
public int TricksWon { get; set; }
|
public int TricksWon { get; set; }
|
||||||
public int Rounds { get; set; }
|
public int Rounds { get; set; }
|
||||||
|
public bool Shutout { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AchievementUnlockDto
|
public class AchievementUnlockDto
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ public class ProfileService
|
|||||||
var p = await GetOrCreate(uid, null);
|
var p = await GetOrCreate(uid, null);
|
||||||
if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!;
|
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("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("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("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()!;
|
if (patch.TryGetProperty("cardBack", out var cb) && cb.ValueKind == JsonValueKind.String) p.CardBack = cb.GetString()!;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen";
|
|||||||
import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen";
|
import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen";
|
||||||
import { ShopScreen } from "@/components/screens/ShopScreen";
|
import { ShopScreen } from "@/components/screens/ShopScreen";
|
||||||
import { BuyCoinsScreen } from "@/components/screens/BuyCoinsScreen";
|
import { BuyCoinsScreen } from "@/components/screens/BuyCoinsScreen";
|
||||||
|
import { AchievementsScreen } from "@/components/screens/AchievementsScreen";
|
||||||
import { ChatScreen } from "@/components/screens/ChatScreen";
|
import { ChatScreen } from "@/components/screens/ChatScreen";
|
||||||
import { NotificationsScreen } from "@/components/screens/NotificationsScreen";
|
import { NotificationsScreen } from "@/components/screens/NotificationsScreen";
|
||||||
import { AuthScreen } from "@/components/screens/AuthScreen";
|
import { AuthScreen } from "@/components/screens/AuthScreen";
|
||||||
@@ -168,6 +169,8 @@ function renderScreen(screen: string) {
|
|||||||
return <ShopScreen />;
|
return <ShopScreen />;
|
||||||
case "buycoins":
|
case "buycoins":
|
||||||
return <BuyCoinsScreen />;
|
return <BuyCoinsScreen />;
|
||||||
|
case "achievements":
|
||||||
|
return <AchievementsScreen />;
|
||||||
case "chat":
|
case "chat":
|
||||||
return <ChatScreen />;
|
return <ChatScreen />;
|
||||||
case "notifications":
|
case "notifications":
|
||||||
|
|||||||
@@ -2,11 +2,29 @@
|
|||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
|
import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
import { RewardResult } from "@/lib/online/types";
|
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 <span className="tabular-nums">{n.toLocaleString()}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
export function PostMatchRewardsModal({
|
export function PostMatchRewardsModal({
|
||||||
reward,
|
reward,
|
||||||
won,
|
won,
|
||||||
@@ -52,6 +70,26 @@ export function PostMatchRewardsModal({
|
|||||||
{won ? t("reward.win") : t("reward.lose")}
|
{won ? t("reward.win") : t("reward.lose")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Coins-won hero (animated count-up) */}
|
||||||
|
{won && reward.coinsDelta > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.6, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 170, damping: 14, delay: 0.15 }}
|
||||||
|
className="mt-4 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-4xl font-black gold-text">
|
||||||
|
+<CountUp to={reward.coinsDelta} />
|
||||||
|
</span>
|
||||||
|
<motion.span
|
||||||
|
animate={{ rotate: [0, -12, 12, 0], y: [0, -4, 0] }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<Coins className="size-8 text-gold-400" />
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-5 space-y-2.5">
|
<div className="mt-5 space-y-2.5">
|
||||||
{reward.ratingDelta !== 0 && (
|
{reward.ratingDelta !== 0 && (
|
||||||
<RewardRow
|
<RewardRow
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Check, Coins, Sticker } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||||
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import {
|
||||||
|
ACHIEVEMENTS,
|
||||||
|
ACHIEVEMENT_CATEGORIES,
|
||||||
|
achievementProgress,
|
||||||
|
stickerPackForAchievement,
|
||||||
|
} from "@/lib/online/gamification";
|
||||||
|
import type { AchievementCategoryId } from "@/lib/online/types";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export function AchievementsScreen() {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const profile = useSessionStore((s) => s.profile);
|
||||||
|
const [tab, setTab] = useState<AchievementCategoryId>("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 (
|
||||||
|
<ScreenShell>
|
||||||
|
<ScreenHeader title={t("achv.title")} />
|
||||||
|
|
||||||
|
{/* summary */}
|
||||||
|
<div className="glass rounded-2xl p-4 mb-4 flex items-center justify-around text-center">
|
||||||
|
<Stat value={`${unlockedCount}/${ACHIEVEMENTS.length}`} label={t("achv.unlocked")} />
|
||||||
|
<div className="h-8 w-px bg-cream/10" />
|
||||||
|
<Stat
|
||||||
|
value={coinsEarned.toLocaleString()}
|
||||||
|
label={t("achv.coinsEarned")}
|
||||||
|
icon={<Coins className="size-3.5 text-gold-400" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* category tabs */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 mb-3">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setTab(c.id)}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full px-3.5 py-2 text-sm font-bold transition flex items-center gap-1.5",
|
||||||
|
active ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{c.icon}</span>
|
||||||
|
<span>{locale === "fa" ? c.nameFa : c.nameEn}</span>
|
||||||
|
<span className={cn("text-[10px]", active ? "text-[#2a1f04]/70" : "text-cream/40")}>
|
||||||
|
{done}/{total}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* achievement list */}
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
{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 (
|
||||||
|
<motion.div
|
||||||
|
key={a.id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl p-3 flex items-center gap-3 border",
|
||||||
|
unlocked ? "bg-gold-500/10 border-gold-500/40" : "bg-navy-900/50 border-navy-700/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("text-3xl shrink-0", !unlocked && "grayscale opacity-50")}>
|
||||||
|
{a.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-cream">
|
||||||
|
{locale === "fa" ? a.nameFa : a.nameEn}
|
||||||
|
</span>
|
||||||
|
{pack && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/15 text-teal-300 text-[10px] px-1.5 py-0.5">
|
||||||
|
<Sticker className="size-3" />
|
||||||
|
{t("achv.unlocksSticker")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-cream/55 mt-0.5">
|
||||||
|
{locale === "fa" ? a.descFa : a.descEn}
|
||||||
|
</div>
|
||||||
|
{!unlocked && a.goal > 1 && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 rounded-full bg-navy-900 overflow-hidden">
|
||||||
|
<div className="h-full bg-gold-500/70" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-cream/45 tabular-nums">
|
||||||
|
{prog}/{a.goal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-end">
|
||||||
|
{unlocked ? (
|
||||||
|
<Check className="size-5 text-gold-400" />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-xs text-gold-300/90 font-bold">
|
||||||
|
+{a.coinReward}
|
||||||
|
<Coins className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xl font-black gold-text">
|
||||||
|
{icon}
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-cream/55 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ export function GameScreen() {
|
|||||||
tricksWon: tally.tricksTeam0,
|
tricksWon: tally.tricksTeam0,
|
||||||
rounds: game.matchScore[0] + game.matchScore[1],
|
rounds: game.matchScore[0] + game.matchScore[1],
|
||||||
trump: game.trump,
|
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()
|
getService()
|
||||||
.submitMatchResult(summary)
|
.submitMatchResult(summary)
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
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 { useState } from "react";
|
||||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||||
|
import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification";
|
||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
const ENTRIES = [100, 500, 1000];
|
|
||||||
|
|
||||||
export function OnlineLobbyScreen() {
|
export function OnlineLobbyScreen() {
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const createRoom = useOnlineStore((s) => s.createRoom);
|
const createRoom = useOnlineStore((s) => s.createRoom);
|
||||||
const startMatchmaking = useOnlineStore((s) => s.startMatchmaking);
|
const startMatchmaking = useOnlineStore((s) => s.startMatchmaking);
|
||||||
const go = useUIStore((s) => s.go);
|
const go = useUIStore((s) => s.go);
|
||||||
const coins = useSessionStore((s) => s.profile?.coins ?? 0);
|
const profile = useSessionStore((s) => s.profile);
|
||||||
const [entry, setEntry] = useState(100);
|
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.
|
// Private rooms with friends are free.
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
@@ -28,6 +32,7 @@ export function OnlineLobbyScreen() {
|
|||||||
|
|
||||||
// Ranked random always costs the entry (you stake it).
|
// Ranked random always costs the entry (you stake it).
|
||||||
const onRandom = async () => {
|
const onRandom = async () => {
|
||||||
|
if (lockedLeague) return;
|
||||||
if (coins < entry) {
|
if (coins < entry) {
|
||||||
go("buycoins");
|
go("buycoins");
|
||||||
return;
|
return;
|
||||||
@@ -48,27 +53,59 @@ export function OnlineLobbyScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* entry (only for ranked) */}
|
{/* league pick (only for ranked) */}
|
||||||
<div className="glass rounded-2xl p-4 mb-4">
|
<div className="glass rounded-2xl p-4 mb-4">
|
||||||
<div className="flex items-center gap-1.5 text-sm text-cream/70 mb-2.5">
|
<div className="flex items-center gap-1.5 text-sm text-cream/70 mb-2.5">
|
||||||
<Coins className="size-4 text-gold-400" />
|
<Trophy className="size-4 text-gold-400" />
|
||||||
{t("lobby.entry")}
|
{t("lobby.chooseLeague")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="space-y-2">
|
||||||
{ENTRIES.map((s) => (
|
{MATCH_LEAGUES.map((l) => {
|
||||||
|
const locked = level < l.minLevel;
|
||||||
|
const active = l.id === leagueId;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={l.id}
|
||||||
onClick={() => setEntry(s)}
|
disabled={locked}
|
||||||
|
onClick={() => setLeagueId(l.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded-xl py-2.5 text-sm font-bold transition",
|
"w-full rounded-2xl p-3 flex items-center gap-3 border text-start transition",
|
||||||
entry === s ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
|
active
|
||||||
|
? "border-gold-500/70 bg-gold-500/10"
|
||||||
|
: "border-navy-700/60 bg-navy-900/50 hover:border-navy-600",
|
||||||
|
locked && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s.toLocaleString()}
|
<span
|
||||||
|
className="size-10 rounded-xl flex items-center justify-center text-xl shrink-0"
|
||||||
|
style={{ background: l.color + "22" }}
|
||||||
|
>
|
||||||
|
{l.icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block text-sm font-black text-cream">
|
||||||
|
{locale === "fa" ? l.nameFa : l.nameEn}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[11px] text-cream/55">
|
||||||
|
{locale === "fa" ? l.descFa : l.descEn}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{locked ? (
|
||||||
|
<span className="text-[11px] text-rose-300 flex items-center gap-1 shrink-0">
|
||||||
|
<Lock className="size-3.5" />
|
||||||
|
{t("lobby.lvl")} {l.minLevel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-gold-300 font-black text-sm shrink-0">
|
||||||
|
{l.entry.toLocaleString()}
|
||||||
|
<Coins className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{coins < entry && (
|
{!lockedLeague && coins < entry && (
|
||||||
<p className="text-rose-300 text-xs mt-2 text-center">{t("lobby.needCoins")}</p>
|
<p className="text-rose-300 text-xs mt-2 text-center">{t("lobby.needCoins")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useRef, useState } from "react";
|
||||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||||
import { RankBadge } from "@/components/online/RankBadge";
|
import { RankBadge } from "@/components/online/RankBadge";
|
||||||
@@ -8,6 +8,7 @@ import { XpBar } from "@/components/online/XpBar";
|
|||||||
import { Avatar } from "@/components/online/Avatar";
|
import { Avatar } from "@/components/online/Avatar";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useSoundStore } from "@/lib/sound-store";
|
import { useSoundStore } from "@/lib/sound-store";
|
||||||
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import {
|
import {
|
||||||
ACHIEVEMENTS,
|
ACHIEVEMENTS,
|
||||||
@@ -18,6 +19,9 @@ import {
|
|||||||
ownedCardBackIds,
|
ownedCardBackIds,
|
||||||
ownedCardFrontIds,
|
ownedCardFrontIds,
|
||||||
} from "@/lib/online/gamification";
|
} 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 { AVATARS } from "@/lib/online/types";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
@@ -26,11 +30,13 @@ export function ProfileScreen() {
|
|||||||
const profile = useSessionStore((s) => s.profile);
|
const profile = useSessionStore((s) => s.profile);
|
||||||
const updateProfile = useSessionStore((s) => s.updateProfile);
|
const updateProfile = useSessionStore((s) => s.updateProfile);
|
||||||
const upgradePlan = useSessionStore((s) => s.upgradePlan);
|
const upgradePlan = useSessionStore((s) => s.upgradePlan);
|
||||||
|
const go = useUIStore((st) => st.go);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [name, setName] = useState(profile?.displayName ?? "");
|
const [name, setName] = useState(profile?.displayName ?? "");
|
||||||
|
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
|
const canUpload = profile.level >= PHOTO_UPLOAD_MIN_LEVEL;
|
||||||
const s = profile.stats;
|
const s = profile.stats;
|
||||||
const winrate = s.games > 0 ? Math.round((s.wins / s.games) * 100) : 0;
|
const winrate = s.games > 0 ? Math.round((s.wins / s.games) * 100) : 0;
|
||||||
const titleDef = TITLES.find((x) => x.id === profile.title);
|
const titleDef = TITLES.find((x) => x.id === profile.title);
|
||||||
@@ -45,7 +51,7 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file || !canUpload) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => updateProfile({ avatarImage: String(reader.result) });
|
reader.onload = () => updateProfile({ avatarImage: String(reader.result) });
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
@@ -62,11 +68,14 @@ export function ProfileScreen() {
|
|||||||
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 80 : 56} />
|
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 80 : 56} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => (canUpload ? fileRef.current?.click() : undefined)}
|
||||||
className="absolute -bottom-1 ltr:-right-1 rtl:-left-1 btn-gold rounded-full p-1.5"
|
className={cn(
|
||||||
title={t("profile.upload")}
|
"absolute -bottom-1 ltr:-right-1 rtl:-left-1 rounded-full p-1.5",
|
||||||
|
canUpload ? "btn-gold" : "bg-navy-900 gold-border text-cream/50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
title={canUpload ? t("profile.upload") : t("profile.uploadLocked")}
|
||||||
>
|
>
|
||||||
<Upload className="size-3.5" />
|
{canUpload ? <Upload className="size-3.5" /> : <Lock className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUpload} />
|
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUpload} />
|
||||||
</div>
|
</div>
|
||||||
@@ -242,10 +251,32 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
{/* achievements */}
|
{/* achievements */}
|
||||||
<div className="glass rounded-2xl p-4 mt-4 mb-6">
|
<div className="glass rounded-2xl p-4 mt-4 mb-6">
|
||||||
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.achievements")}</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-bold text-cream/80">
|
||||||
|
{t("profile.achievements")}
|
||||||
|
<span className="text-cream/40 font-normal">
|
||||||
|
{" "}
|
||||||
|
({profile.unlocked.length}/{ACHIEVEMENTS.length})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => go("achievements")}
|
||||||
|
className="text-xs font-bold text-gold-300 flex items-center gap-0.5 hover:text-gold-200"
|
||||||
|
>
|
||||||
|
{t("achv.viewAll")}
|
||||||
|
<ChevronLeft className="size-3.5 rtl:rotate-0 ltr:rotate-180" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{ACHIEVEMENTS.map((a) => {
|
{[...ACHIEVEMENTS]
|
||||||
const prog = achievementProgress(a.id, s, profile.rating);
|
.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 unlocked = profile.unlocked.includes(a.id) || prog >= a.goal;
|
||||||
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
|
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ const fa: Dict = {
|
|||||||
"resume.matchEnded": "بازی به پایان رسید",
|
"resume.matchEnded": "بازی به پایان رسید",
|
||||||
"resume.matchEndedBody": "نتیجه و جایزه را ببینید",
|
"resume.matchEndedBody": "نتیجه و جایزه را ببینید",
|
||||||
|
|
||||||
|
"achv.title": "دستاوردها",
|
||||||
|
"achv.unlocked": "باز شده",
|
||||||
|
"achv.coinsEarned": "سکه کسبشده",
|
||||||
|
"achv.viewAll": "همه",
|
||||||
|
"achv.unlocksSticker": "استیکر",
|
||||||
|
"lobby.chooseLeague": "لیگ را انتخاب کنید",
|
||||||
|
"lobby.lvl": "سطح",
|
||||||
|
"profile.uploadLocked": "آپلود عکس از سطح ۲۵ فعال میشود",
|
||||||
|
|
||||||
"seat.you": "شما",
|
"seat.you": "شما",
|
||||||
"team.us": "ما",
|
"team.us": "ما",
|
||||||
"team.them": "حریف",
|
"team.them": "حریف",
|
||||||
@@ -280,6 +289,15 @@ const en: Dict = {
|
|||||||
"resume.matchEnded": "Match ended",
|
"resume.matchEnded": "Match ended",
|
||||||
"resume.matchEndedBody": "See the result and reward",
|
"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",
|
"seat.you": "You",
|
||||||
"team.us": "Us",
|
"team.us": "Us",
|
||||||
"team.them": "Them",
|
"team.them": "Them",
|
||||||
|
|||||||
+136
-42
@@ -2,11 +2,13 @@
|
|||||||
// daily rewards, achievements. No side effects, no storage — unit-testable.
|
// daily rewards, achievements. No side effects, no storage — unit-testable.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AchievementCategoryDef,
|
||||||
AchievementDef,
|
AchievementDef,
|
||||||
AchievementUnlock,
|
AchievementUnlock,
|
||||||
CardBackDef,
|
CardBackDef,
|
||||||
CardFrontDef,
|
CardFrontDef,
|
||||||
LeagueInfo,
|
LeagueInfo,
|
||||||
|
MatchLeague,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
PlayerStats,
|
PlayerStats,
|
||||||
RankTier,
|
RankTier,
|
||||||
@@ -106,11 +108,25 @@ export function ratingDelta(
|
|||||||
export function coinDelta(summary: MatchSummary): number {
|
export function coinDelta(summary: MatchSummary): number {
|
||||||
// Free games (vs computer / private friend rooms) never touch coins.
|
// Free games (vs computer / private friend rooms) never touch coins.
|
||||||
if (!summary.ranked) return 0;
|
if (!summary.ranked) return 0;
|
||||||
// Ranked: win the stake (+kot bonus), lose the stake.
|
// Ranked: win the stake (+kot bonus scaled to the league), lose the stake.
|
||||||
const kotBonus = summary.won && summary.kotFor ? 40 : 0;
|
// 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;
|
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 ---------------------------------- */
|
||||||
|
|
||||||
/** XP required to advance from `level` to `level + 1`. */
|
/** XP required to advance from `level` to `level + 1`. */
|
||||||
@@ -147,50 +163,114 @@ export function addXp(level: number, xpInLevel: number, gained: number): LevelPr
|
|||||||
|
|
||||||
/* --------------------------- Achievements ---------------------------- */
|
/* --------------------------- Achievements ---------------------------- */
|
||||||
|
|
||||||
export const ACHIEVEMENTS: AchievementDef[] = [
|
export const ACHIEVEMENT_CATEGORIES: AchievementCategoryDef[] = [
|
||||||
{ id: "first_win", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game", icon: "🥇", goal: 1, coinReward: 100 },
|
{ id: "victory", nameFa: "بردها", nameEn: "Victories", icon: "🏆" },
|
||||||
{ id: "first_kot", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "حریف را کُت کنید", descEn: "Inflict a Kot on opponents", icon: "🔥", goal: 1, coinReward: 150 },
|
{ id: "kot", nameFa: "کُت", nameEn: "Kot", icon: "🔥" },
|
||||||
{ id: "wins_10", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games", icon: "🎯", goal: 10, coinReward: 300 },
|
{ id: "streak", nameFa: "نوار پیروزی", nameEn: "Streaks", icon: "⚡" },
|
||||||
{ id: "wins_100", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید", descEn: "Win 100 games", icon: "👑", goal: 100, coinReward: 2000 },
|
{ id: "level", nameFa: "سطح", nameEn: "Levels", icon: "⭐" },
|
||||||
{ id: "streak_5", nameFa: "نوار ۵ برد", nameEn: "5 Win Streak", descFa: "۵ برد پیاپی", descEn: "Win 5 in a row", icon: "⚡", goal: 5, coinReward: 400 },
|
{ id: "rank", nameFa: "لیگ", nameEn: "Ranks", icon: "🏅" },
|
||||||
{ id: "reach_gold", nameFa: "رسیدن به طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league", icon: "🏅", goal: 1, coinReward: 500 },
|
{ id: "veteran", nameFa: "کارنامه", nameEn: "Veterancy", icon: "🎮" },
|
||||||
{ id: "games_50", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games", icon: "🎮", goal: 50, coinReward: 350 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Current raw progress value for an achievement from stats + rating. */
|
export const ACHIEVEMENTS: AchievementDef[] = [
|
||||||
export function achievementProgress(
|
// ---- Victories (wins + shutouts) ----
|
||||||
id: string,
|
{ id: "first_win", category: "victory", metric: "wins", goal: 1, coinReward: 100, icon: "🥇", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game" },
|
||||||
stats: PlayerStats,
|
{ id: "wins_10", category: "victory", metric: "wins", goal: 10, coinReward: 300, icon: "🎯", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games" },
|
||||||
rating: number
|
{ id: "wins_25", category: "victory", metric: "wins", goal: 25, coinReward: 600, icon: "🏅", nameFa: "۲۵ برد", nameEn: "25 Wins", descFa: "۲۵ بازی ببرید", descEn: "Win 25 games" },
|
||||||
): number {
|
{ id: "wins_50", category: "victory", metric: "wins", goal: 50, coinReward: 1000, icon: "🏆", nameFa: "۵۰ برد", nameEn: "50 Wins", descFa: "۵۰ بازی ببرید", descEn: "Win 50 games" },
|
||||||
switch (id) {
|
{ id: "wins_100", category: "victory", metric: "wins", goal: 100, coinReward: 2000, icon: "👑", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید (پک استیکر ایرانی)", descEn: "Win 100 games (unlocks Persian stickers)" },
|
||||||
case "first_win":
|
{ id: "wins_250", category: "victory", metric: "wins", goal: 250, coinReward: 4000, icon: "💎", nameFa: "۲۵۰ برد", nameEn: "250 Wins", descFa: "۲۵۰ بازی ببرید", descEn: "Win 250 games" },
|
||||||
return Math.min(1, stats.wins);
|
{ id: "wins_500", category: "victory", metric: "wins", goal: 500, coinReward: 8000, icon: "🌟", nameFa: "۵۰۰ برد", nameEn: "500 Wins", descFa: "۵۰۰ بازی ببرید", descEn: "Win 500 games" },
|
||||||
case "first_kot":
|
{ 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)" },
|
||||||
return Math.min(1, stats.kotsFor);
|
{ id: "shutout_5", category: "victory", metric: "shutoutWins", goal: 5, coinReward: 900, icon: "🧨", nameFa: "۵ بار هفت–هیچ", nameEn: "5× Sweep", descFa: "۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 5 times" },
|
||||||
case "wins_10":
|
{ id: "shutout_25", category: "victory", metric: "shutoutWins", goal: 25, coinReward: 3000, icon: "☄️", nameFa: "۲۵ بار هفت–هیچ", nameEn: "25× Sweep", descFa: "۲۵ بار حریف را ۷–۰ ببرید", descEn: "Sweep the opponent 25 times" },
|
||||||
return Math.min(10, stats.wins);
|
|
||||||
case "wins_100":
|
// ---- Kot ----
|
||||||
return Math.min(100, stats.wins);
|
{ id: "first_kot", category: "kot", metric: "kotsFor", goal: 1, coinReward: 150, icon: "🔥", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "یک بار حریف را کُت کنید", descEn: "Inflict a Kot once" },
|
||||||
case "streak_5":
|
{ id: "kot_5", category: "kot", metric: "kotsFor", goal: 5, coinReward: 300, icon: "🌶️", nameFa: "۵ کُت", nameEn: "5 Kots", descFa: "۵ بار حریف را کُت کنید", descEn: "Inflict 5 Kots" },
|
||||||
return Math.min(5, stats.bestWinStreak);
|
{ id: "kot_10", category: "kot", metric: "kotsFor", goal: 10, coinReward: 500, icon: "🔥", nameFa: "۱۰ کُت", nameEn: "10 Kots", descFa: "۱۰ بار حریف را کُت کنید", descEn: "Inflict 10 Kots" },
|
||||||
case "reach_gold":
|
{ id: "kot_25", category: "kot", metric: "kotsFor", goal: 25, coinReward: 1200, icon: "💥", nameFa: "۲۵ کُت", nameEn: "25 Kots", descFa: "۲۵ بار حریف را کُت کنید (پک استیکر طعنه)", descEn: "Inflict 25 Kots (unlocks Taunt stickers)" },
|
||||||
return rating >= tierById("gold").floor ? 1 : 0;
|
{ id: "kot_50", category: "kot", metric: "kotsFor", goal: 50, coinReward: 2500, icon: "⚡", nameFa: "۵۰ کُت", nameEn: "50 Kots", descFa: "۵۰ بار حریف را کُت کنید", descEn: "Inflict 50 Kots" },
|
||||||
case "games_50":
|
{ id: "kot_100", category: "kot", metric: "kotsFor", goal: 100, coinReward: 5000, icon: "👹", nameFa: "۱۰۰ کُت", nameEn: "100 Kots", descFa: "۱۰۰ بار حریف را کُت کنید", descEn: "Inflict 100 Kots" },
|
||||||
return Math.min(50, stats.games);
|
|
||||||
default:
|
// ---- Streaks ----
|
||||||
return 0;
|
{ 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 ------------------------------- */
|
/* ------------------------------ Titles ------------------------------- */
|
||||||
|
|
||||||
export const TITLES: TitleDef[] = [
|
export const TITLES: TitleDef[] = [
|
||||||
{ id: "novice", nameFa: "تازهکار", nameEn: "Novice", hintFa: "پیشفرض", hintEn: "Default" },
|
{ id: "novice", nameFa: "تازهکار", nameEn: "Novice", hintFa: "پیشفرض", hintEn: "Default" },
|
||||||
{ id: "winner", nameFa: "برنده", nameEn: "Winner", hintFa: "۱۰ برد", hintEn: "10 wins" },
|
{ id: "winner", nameFa: "برنده", nameEn: "Winner", hintFa: "۱۰ برد", hintEn: "10 wins" },
|
||||||
{ id: "kot_master", nameFa: "استاد کُت", nameEn: "Kot Master", hintFa: "۱۰ کُت", hintEn: "10 kots" },
|
{ id: "expert", nameFa: "خبره", nameEn: "Expert", hintFa: "سطح ۲۵", hintEn: "Level 25" },
|
||||||
{ id: "veteran", nameFa: "کهنهکار", nameEn: "Veteran", hintFa: "سطح ۲۰", hintEn: "Level 20" },
|
{ 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: "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" },
|
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -205,12 +285,20 @@ export function titleUnlocked(
|
|||||||
return true;
|
return true;
|
||||||
case "winner":
|
case "winner":
|
||||||
return stats.wins >= 10;
|
return stats.wins >= 10;
|
||||||
|
case "expert":
|
||||||
|
return level >= 25;
|
||||||
case "kot_master":
|
case "kot_master":
|
||||||
return stats.kotsFor >= 10;
|
return stats.kotsFor >= 25;
|
||||||
|
case "professional":
|
||||||
|
return stats.wins >= 50;
|
||||||
case "veteran":
|
case "veteran":
|
||||||
return level >= 20;
|
return level >= 30;
|
||||||
|
case "captain":
|
||||||
|
return stats.wins >= 100;
|
||||||
case "champion":
|
case "champion":
|
||||||
return rating >= tierById("gold").floor;
|
return rating >= tierById("gold").floor;
|
||||||
|
case "leader":
|
||||||
|
return stats.wins >= 250;
|
||||||
case "legend":
|
case "legend":
|
||||||
return rating >= tierById("master").floor;
|
return rating >= tierById("master").floor;
|
||||||
default:
|
default:
|
||||||
@@ -304,9 +392,12 @@ export function ownedReactions(profile: UserProfile): string[] {
|
|||||||
|
|
||||||
export const STICKER_PACKS: StickerPackDef[] = [
|
export const STICKER_PACKS: StickerPackDef[] = [
|
||||||
{ id: "faces", nameFa: "شکلکها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true },
|
{ 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 },
|
// Earned by the "Seven–Zip" (7–0 sweep) achievement.
|
||||||
{ id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700 },
|
{ id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 0, unlockAchievement: "shutout_1" },
|
||||||
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900 },
|
// 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 {
|
export function stickerPackById(id: string): StickerPackDef | undefined {
|
||||||
@@ -315,11 +406,13 @@ export function stickerPackById(id: string): StickerPackDef | undefined {
|
|||||||
|
|
||||||
export function ownedStickerPackIds(profile: UserProfile): string[] {
|
export function ownedStickerPackIds(profile: UserProfile): string[] {
|
||||||
const purchased = profile.ownedStickerPacks ?? [];
|
const purchased = profile.ownedStickerPacks ?? [];
|
||||||
|
const unlocked = profile.unlocked ?? [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const p of STICKER_PACKS) {
|
for (const p of STICKER_PACKS) {
|
||||||
const earned =
|
const earned =
|
||||||
(p.unlockRating != null && profile.rating >= p.unlockRating) ||
|
(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);
|
if (p.default || earned || purchased.includes(p.id)) ids.add(p.id);
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
@@ -346,6 +439,7 @@ function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
|
|||||||
tricks: stats.tricks + summary.tricksWon,
|
tricks: stats.tricks + summary.tricksWon,
|
||||||
currentWinStreak,
|
currentWinStreak,
|
||||||
bestWinStreak: Math.max(stats.bestWinStreak, 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[] = [];
|
const newAchievements: AchievementUnlock[] = [];
|
||||||
let achievementCoins = 0;
|
let achievementCoins = 0;
|
||||||
for (const def of ACHIEVEMENTS) {
|
for (const def of ACHIEVEMENTS) {
|
||||||
const prog = achievementProgress(def.id, stats, ratingAfter);
|
const prog = achievementProgress(def, stats, ratingAfter, lvl.level);
|
||||||
achievements[def.id] = prog;
|
achievements[def.id] = prog;
|
||||||
if (prog >= def.goal && !unlocked.includes(def.id)) {
|
if (prog >= def.goal && !unlocked.includes(def.id)) {
|
||||||
unlocked.push(def.id);
|
unlocked.push(def.id);
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function defaultProfile(session: AuthSession): UserProfile {
|
|||||||
tricks: 0,
|
tricks: 0,
|
||||||
bestWinStreak: 0,
|
bestWinStreak: 0,
|
||||||
currentWinStreak: 0,
|
currentWinStreak: 0,
|
||||||
|
shutoutWins: 0,
|
||||||
},
|
},
|
||||||
ownedAvatars: [AVATARS[0].id, AVATARS[1].id],
|
ownedAvatars: [AVATARS[0].id, AVATARS[1].id],
|
||||||
ownedCardFronts: ["classic"],
|
ownedCardFronts: ["classic"],
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface PlayerStats {
|
|||||||
tricks: number;
|
tricks: number;
|
||||||
bestWinStreak: number;
|
bestWinStreak: number;
|
||||||
currentWinStreak: number;
|
currentWinStreak: number;
|
||||||
|
shutoutWins: number; // matches won with the opponent on 0 rounds (e.g. 7–0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlanId = "free" | "pro";
|
export type PlanId = "free" | "pro";
|
||||||
@@ -99,8 +100,27 @@ export interface LeagueInfo {
|
|||||||
|
|
||||||
/* --------------------------- Achievements ---------------------------- */
|
/* --------------------------- 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 {
|
export interface AchievementDef {
|
||||||
id: string;
|
id: string;
|
||||||
|
category: AchievementCategoryId;
|
||||||
nameFa: string;
|
nameFa: string;
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
descFa: string;
|
descFa: string;
|
||||||
@@ -108,6 +128,17 @@ export interface AchievementDef {
|
|||||||
icon: string; // emoji or lucide name
|
icon: string; // emoji or lucide name
|
||||||
goal: number; // progress needed to unlock
|
goal: number; // progress needed to unlock
|
||||||
coinReward: number;
|
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 {
|
export interface AchievementView extends AchievementDef {
|
||||||
@@ -115,6 +146,19 @@ export interface AchievementView extends AchievementDef {
|
|||||||
unlocked: boolean;
|
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 ------------------------ */
|
/* ----------------------- Titles & card styles ------------------------ */
|
||||||
|
|
||||||
export interface TitleDef {
|
export interface TitleDef {
|
||||||
@@ -174,6 +218,7 @@ export interface StickerPackDef {
|
|||||||
default?: boolean;
|
default?: boolean;
|
||||||
unlockRating?: number;
|
unlockRating?: number;
|
||||||
unlockWins?: number;
|
unlockWins?: number;
|
||||||
|
unlockAchievement?: string; // earned when this achievement id is unlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ Friends ------------------------------ */
|
/* ------------------------------ Friends ------------------------------ */
|
||||||
@@ -264,6 +309,7 @@ export interface MatchSummary {
|
|||||||
tricksWon: number; // your team's total tricks across the match
|
tricksWon: number; // your team's total tricks across the match
|
||||||
rounds: number;
|
rounds: number;
|
||||||
trump: Suit | null;
|
trump: Suit | null;
|
||||||
|
shutout: boolean; // won with the opponent on 0 rounds (e.g. 7–0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AchievementUnlock {
|
export interface AchievementUnlock {
|
||||||
|
|||||||
+3
-2
@@ -13,18 +13,19 @@ export type Screen =
|
|||||||
| "leaderboard"
|
| "leaderboard"
|
||||||
| "shop"
|
| "shop"
|
||||||
| "buycoins"
|
| "buycoins"
|
||||||
|
| "achievements"
|
||||||
| "chat"
|
| "chat"
|
||||||
| "notifications"
|
| "notifications"
|
||||||
| "game"; // the table (used for both ai + online)
|
| "game"; // the table (used for both ai + online)
|
||||||
|
|
||||||
const ALL_SCREENS: Screen[] = [
|
const ALL_SCREENS: Screen[] = [
|
||||||
"home", "auth", "profile", "friends", "online",
|
"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). */
|
/** Screens safe to restore from a URL on a cold load (no transient state needed). */
|
||||||
export const STATIC_SCREENS: Screen[] = [
|
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 {
|
export function screenFromHash(): Screen {
|
||||||
|
|||||||
Reference in New Issue
Block a user