// Pure gamification rules: ranks/leagues, rating, XP/levels, coins, // daily rewards, achievements. No side effects, no storage — unit-testable. import { AVATARS, GIFT_TIERS, AchievementCategoryDef, AchievementCategoryId, AchievementDef, AchievementMetric, AchievementUnlock, CardBackDef, CardFrontDef, LeagueInfo, MatchLeague, MatchSummary, PlayerStats, RankTier, RankTierId, ReactionPackDef, RewardResult, StickerPackDef, TitleDef, TitleUnlock, UserProfile, } from "./types"; /* ------------------------------- Ranks ------------------------------- */ export const RANK_TIERS: RankTier[] = [ { id: "bronze", nameFa: "برنز", nameEn: "Bronze", floor: 0, color: "#cd7f32" }, { id: "silver", nameFa: "نقره", nameEn: "Silver", floor: 1100, color: "#c0c7d0" }, { id: "gold", nameFa: "طلا", nameEn: "Gold", floor: 1300, color: "#e6b800" }, { id: "platinum", nameFa: "پلاتین", nameEn: "Platinum", floor: 1500, color: "#46c2c2" }, { id: "diamond", nameFa: "الماس", nameEn: "Diamond", floor: 1700, color: "#6aa6ff" }, { id: "master", nameFa: "استاد", nameEn: "Master", floor: 1900, color: "#c77dff" }, ]; const ROMAN = ["", "I", "II", "III"]; export function divisionLabel(division: number | null): string { if (division == null) return ""; return ROMAN[division] ?? ""; } export function tierById(id: RankTierId): RankTier { return RANK_TIERS.find((t) => t.id === id) ?? RANK_TIERS[0]; } export function getLeagueInfo(rating: number): LeagueInfo { const r = Math.max(0, Math.round(rating)); let idx = 0; for (let i = 0; i < RANK_TIERS.length; i++) { if (r >= RANK_TIERS[i].floor) idx = i; } const tier = RANK_TIERS[idx]; const isLast = idx === RANK_TIERS.length - 1; if (isLast) { return { tier, division: null, rating: r, nextThreshold: null, progress: 1 }; } const nextTierFloor = RANK_TIERS[idx + 1].floor; const band = nextTierFloor - tier.floor; const third = band / 3; // division 3 (III) is lowest, 1 (I) is highest const within = r - tier.floor; let division: number; let divStart: number; let divEnd: number; if (within < third) { division = 3; divStart = tier.floor; divEnd = tier.floor + third; } else if (within < 2 * third) { division = 2; divStart = tier.floor + third; divEnd = tier.floor + 2 * third; } else { division = 1; divStart = tier.floor + 2 * third; divEnd = nextTierFloor; } const progress = Math.min(1, Math.max(0, (r - divStart) / (divEnd - divStart))); return { tier, division, rating: r, nextThreshold: Math.round(divEnd), progress }; } /* ------------------------------ Rating ------------------------------- */ const K_FACTOR = 32; /** Elo-style rating delta for a ranked match (0 for casual). */ export function ratingDelta( summary: MatchSummary, myRating: number, oppRating: number ): number { if (!summary.ranked) return 0; const expected = 1 / (1 + Math.pow(10, (oppRating - myRating) / 400)); const score = summary.won ? 1 : 0; let delta = K_FACTOR * (score - expected); if (summary.won && summary.kotFor) delta += 8; if (!summary.won && summary.kotAgainst) delta -= 8; const rounded = Math.round(delta); // never let a win cost rating or a loss gain it if (summary.won) return Math.max(1, rounded); return Math.min(-1, rounded); } /* ------------------------------- Coins ------------------------------- */ export function coinDelta(summary: MatchSummary): number { // Free games (vs computer / private friend rooms) never touch coins. if (!summary.ranked) return 0; // Forfeit: the surrendering team loses double the stake; the winner takes the stake. if (summary.forfeit) return summary.won ? summary.stake : -2 * summary.stake; // 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]; } /** Coin-priced XP packs (XP is intentionally expensive). Server-authoritative. */ export const XP_PACKS: { id: string; xp: number; price: number }[] = [ { id: "xp1", xp: 200, price: 1500 }, { id: "xp2", xp: 600, price: 4000 }, { id: "xp3", xp: 1500, price: 8000 }, ]; /* ------------------------------- XP ---------------------------------- */ /** Hard level cap. */ export const MAX_LEVEL = 100; /** XP required to advance from `level` to `level + 1` — grows with level, so * each level is harder than the last. */ export function xpNeededForLevel(level: number): number { return 100 * level + 15 * level * level; } /** Higher leagues (bigger stake) grant more XP, so high-level players progress * by playing up rather than grinding the starter league. */ export function leagueXpFactor(stake: number): number { if (stake >= 1000) return 2; if (stake >= 500) return 1.5; return 1; } /** XP multiplier for premium (pro) players. */ export const PREMIUM_XP_MULT = 1.5; /* ----------------------------- Turn time ----------------------------- */ /** * How long a player has to act, by league (derived from the coin stake). Higher * leagues give LESS time, so stronger players must think faster: * Starter / vs-AI / private (stake < 500) → 15s * Pro league (stake ≥ 500) → 10s * Expert league (stake ≥ 1000) → 7s * Both the offline client and the live server use this same mapping so the turn * clock matches in either mode. */ /** Blitz/speed-mode turn time — a flat, fast clock for casual quick games. */ export const SPEED_TURN_MS = 5000; /** Speed mode races to fewer points so a match is over fast. */ export const SPEED_TARGET_SCORE = 5; /** * How long matchmaking waits for real online players before filling the * remaining seats with bots and starting the match. (Mirror of the server's * QueueWaitMs in GameManager.cs — keep both in sync.) */ export const MATCH_QUEUE_WAIT_MS = 15000; export function turnMsForStake(stake: number, speed = false): number { if (speed) return SPEED_TURN_MS; if (stake >= 1000) return 7000; if (stake >= 500) return 10000; return 15000; } export function matchXp(summary: MatchSummary): number { // Forfeiting (surrendering) earns no XP. if (summary.forfeit && !summary.won) return 0; // Every game grants XP; the winner earns double. const base = 40 + summary.tricksWon * 5 + (summary.kotFor ? 30 : 0); return Math.round(base * (summary.won ? 2 : 1) * leagueXpFactor(summary.stake)); } export interface LevelProgress { level: number; xp: number; // xp within the current level leveledUp: boolean; } export function addXp(level: number, xpInLevel: number, gained: number): LevelProgress { let lvl = level; let xp = xpInLevel + gained; let leveledUp = false; while (lvl < MAX_LEVEL && xp >= xpNeededForLevel(lvl)) { xp -= xpNeededForLevel(lvl); lvl += 1; leveledUp = true; } // At the cap, don't let XP overflow the bar. if (lvl >= MAX_LEVEL) { lvl = MAX_LEVEL; xp = Math.min(xp, xpNeededForLevel(MAX_LEVEL)); } return { level: lvl, xp, leveledUp }; } /* --------------------------- Achievements ---------------------------- */ export const ACHIEVEMENT_CATEGORIES: AchievementCategoryDef[] = [ { id: "victory", nameFa: "بردها", nameEn: "Victories", icon: "🏆" }, { id: "kot", nameFa: "کُت", nameEn: "Kot", icon: "🔥" }, { id: "streak", nameFa: "نوار پیروزی", nameEn: "Streaks", icon: "⚡" }, { id: "hakem", nameFa: "حاکمیت", nameEn: "Rulership", icon: "👑" }, { id: "level", nameFa: "سطح", nameEn: "Levels", icon: "⭐" }, { id: "rank", nameFa: "رتبه", nameEn: "Ranks", icon: "🏅" }, { id: "veteran", nameFa: "کارنامه", nameEn: "Veterancy", icon: "🎮" }, ]; const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹"; /** Western → Persian digits, for generated achievement names. */ export function faNum(n: number): string { return String(n).replace(/\d/g, (d) => FA_DIGITS[+d]); } /** Build a tiered family of achievements for one metric (keeps the list DRY). */ function tier( category: AchievementCategoryId, metric: AchievementMetric, prefix: string, icon: string, goals: number[], faName: (g: string) => string, enName: (g: number) => string, faDesc: (g: string) => string, enDesc: (g: number) => string ): AchievementDef[] { return goals.map((g) => ({ id: `${prefix}_${g}`, category, metric, goal: g, coinReward: Math.min(1500, Math.max(50, Math.round((40 + g * 6) / 50) * 50)), icon, nameFa: faName(faNum(g)), nameEn: enName(g), descFa: faDesc(faNum(g)), descEn: enDesc(g), })); } export const ACHIEVEMENTS: AchievementDef[] = [ ...tier("victory", "wins", "wins", "🏆", [1, 5, 10, 25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 2000], (g) => `${g} برد`, (g) => `${g} Wins`, (g) => `${g} بازی ببرید`, (g) => `Win ${g} games`), ...tier("victory", "shutoutWins", "shutout", "🧹", [1, 3, 5, 10, 25, 50, 100], (g) => `${g} بار هفت–هیچ`, (g) => `${g}× Sweep`, (g) => `${g} بار حریف را ۷–۰ ببرید`, (g) => `Sweep the opponent ${g}×`), ...tier("kot", "kotsFor", "kot", "🔥", [1, 3, 5, 10, 25, 50, 75, 100, 150, 200, 300, 500], (g) => `${g} کُت`, (g) => `${g} Kots`, (g) => `${g} بار حریف را کُت کنید`, (g) => `Inflict ${g} Kots`), ...tier("streak", "bestWinStreak", "streak", "⚡", [2, 3, 5, 7, 10, 15, 20, 25, 30, 40], (g) => `${g} برد پیاپی`, (g) => `${g} Win Streak`, (g) => `${g} بازی پشت سر هم ببرید`, (g) => `Win ${g} games in a row`), ...tier("hakem", "hakemRounds", "hakem", "👑", [7, 25, 50, 100, 250, 500, 1000], (g) => `${g} بار حاکم`, (g) => `Hakem ${g}×`, (g) => `${g} دست حاکم شوید`, (g) => `Be the hakem in ${g} rounds`), ...tier("level", "level", "level", "⭐", [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100], (g) => `سطح ${g}`, (g) => `Level ${g}`, (g) => `به سطح ${g} برسید`, (g) => `Reach level ${g}`), ...tier("veteran", "games", "games", "🎮", [10, 25, 50, 100, 200, 300, 500, 750, 1000, 2000, 5000], (g) => `${g} بازی`, (g) => `${g} Games`, (g) => `${g} بازی انجام دهید`, (g) => `Play ${g} games`), ...tier("veteran", "roundsWon", "rounds", "🎴", [25, 100, 250, 500, 1000, 2000, 5000], (g) => `${g} دست برده`, (g) => `${g} Rounds Won`, (g) => `${g} دست ببرید`, (g) => `Win ${g} rounds`), ...tier("veteran", "tricks", "tricks", "🗂️", [50, 100, 250, 500, 1000, 2500, 5000, 10000], (g) => `${g} دست‌برد`, (g) => `${g} Tricks`, (g) => `${g} دست‌برد بگیرید`, (g) => `Win ${g} tricks`), ...tier("veteran", "losses", "grit", "🛡️", [10, 50, 100], (g) => `${g} باخت`, (g) => `${g} Losses`, (g) => `با وجود ${g} باخت ادامه دهید`, (g) => `Persevere through ${g} losses`), // ranks (explicit rating floors) { id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 150, icon: "🥈", nameFa: "رتبهٔ نقره", nameEn: "Reach Silver", descFa: "به رتبهٔ نقره برسید", descEn: "Reach the Silver rank" }, { id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 300, icon: "🥇", nameFa: "رتبهٔ طلا", nameEn: "Reach Gold", descFa: "به رتبهٔ طلا برسید", descEn: "Reach the Gold rank" }, { id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 500, icon: "🛡️", nameFa: "رتبهٔ پلاتین", nameEn: "Reach Platinum", descFa: "به رتبهٔ پلاتین برسید", descEn: "Reach the Platinum rank" }, { id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 900, icon: "💠", nameFa: "رتبهٔ الماس", nameEn: "Reach Diamond", descFa: "به رتبهٔ الماس برسید", descEn: "Reach the Diamond rank" }, { id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 1500, icon: "👑", nameFa: "رتبهٔ استاد", nameEn: "Reach Master", descFa: "به رتبهٔ استاد برسید", descEn: "Reach the Master rank" }, ]; function metricValue(metric: NonNullable, stats: PlayerStats, level: number): number { switch (metric) { case "wins": return stats.wins; case "losses": return stats.losses; 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 "hakemRounds": return stats.hakemRounds ?? 0; case "roundsWon": return stats.roundsWon ?? 0; 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); } /** * Re-evaluate all achievements against the profile's current state (used outside * matches, e.g. after an XP-pack purchase crosses a level milestone). Unlocks new * ones, grants their coin rewards, and returns the newly-unlocked list. */ export function evaluateAchievements(profile: UserProfile): { profile: UserProfile; newAchievements: AchievementUnlock[]; } { const achievements = { ...profile.achievements }; const unlocked = [...profile.unlocked]; const newAchievements: AchievementUnlock[] = []; let coins = 0; for (const def of ACHIEVEMENTS) { const prog = achievementProgress(def, profile.stats, profile.rating, profile.level); achievements[def.id] = prog; if (prog >= def.goal && !unlocked.includes(def.id)) { unlocked.push(def.id); coins += def.coinReward; newAchievements.push({ id: def.id, nameFa: def.nameFa, nameEn: def.nameEn, icon: def.icon, coinReward: def.coinReward, }); } } return { profile: { ...profile, achievements, unlocked, coins: profile.coins + coins }, newAchievements, }; } /** The sticker pack (if any) that this achievement unlocks for purchase. */ export function stickerPackForAchievement(achId: string): StickerPackDef | undefined { return STICKER_PACKS.find((p) => p.reqAchievement === achId); } /* ------------------------------ Titles ------------------------------- */ export const TITLES: TitleDef[] = [ { id: "novice", nameFa: "تازه‌کار", nameEn: "Novice", hintFa: "پیش‌فرض", hintEn: "Default" }, { id: "winner", nameFa: "برنده", nameEn: "Winner", hintFa: "۱۰ برد", hintEn: "10 wins" }, { 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: "marksman", nameFa: "کماندار", nameEn: "Marksman", hintFa: "۵۰ کُت", hintEn: "50 kots" }, { id: "untouchable", nameFa: "شکست‌ناپذیر", nameEn: "Untouchable", hintFa: "۱۰ برد پیاپی", hintEn: "10 win streak" }, { id: "sweeper", nameFa: "جاروکش", nameEn: "Sweeper", hintFa: "۱۰ هفت–هیچ", hintEn: "10 sweeps" }, { id: "ruler", nameFa: "فرمانروا", nameEn: "Ruler", hintFa: "۵۰ بار حاکم", hintEn: "50× hakem" }, { id: "champion", nameFa: "قهرمان", nameEn: "Champion", hintFa: "لیگ طلا", hintEn: "Gold league" }, { id: "platinum_star", nameFa: "ستاره پلاتین", nameEn: "Platinum Star", hintFa: "لیگ پلاتین", hintEn: "Platinum league" }, { id: "leader", nameFa: "فرمانده", nameEn: "Leader", hintFa: "۲۵۰ برد", hintEn: "250 wins" }, { id: "diamond_ace", nameFa: "آس الماس", nameEn: "Diamond Ace", hintFa: "لیگ الماس", hintEn: "Diamond league" }, { id: "immortal", nameFa: "جاودانه", nameEn: "Immortal", hintFa: "سطح ۵۰", hintEn: "Level 50" }, { id: "the_one", nameFa: "یگانه", nameEn: "The One", hintFa: "۵۰۰ برد", hintEn: "500 wins" }, { id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" }, // ✨ Luxury titles — the most prestigious badges in the game { id: "sultan", nameFa: "سلطان حکم", nameEn: "Hokm Sultan", hintFa: "۱۰۰ کُت", hintEn: "100 kots" }, { id: "emperor", nameFa: "امپراتور", nameEn: "Emperor", hintFa: "سطح ۷۵", hintEn: "Level 75" }, { id: "grandmaster", nameFa: "استاد بزرگ", nameEn: "Grandmaster", hintFa: "امتیاز ۲۱۰۰+", hintEn: "2100+ rating" }, // 💰 Purchasable titles — buy with coins (shown in the shop's Titles section) { id: "vip", nameFa: "وی‌آی‌پی", nameEn: "VIP", hintFa: "خرید", hintEn: "Purchase", price: 2500 }, { id: "maestro", nameFa: "اوستا", nameEn: "Maestro", hintFa: "خرید", hintEn: "Purchase", price: 2000 }, { id: "prince", nameFa: "شاهزاده", nameEn: "Prince", hintFa: "خرید", hintEn: "Purchase", price: 3500 }, { id: "mythic", nameFa: "افسانه‌ای", nameEn: "Mythic", hintFa: "خرید", hintEn: "Purchase", price: 6000 }, ]; export function titleById(id: string | null | undefined): TitleDef | undefined { if (!id) return undefined; return TITLES.find((t) => t.id === id); } export function titleUnlocked( id: string, stats: PlayerStats, rating: number, level: number ): boolean { switch (id) { case "novice": return true; case "winner": return stats.wins >= 10; case "expert": return level >= 25; case "kot_master": return stats.kotsFor >= 25; case "professional": return stats.wins >= 50; case "veteran": return level >= 30; case "captain": return stats.wins >= 100; case "marksman": return stats.kotsFor >= 50; case "untouchable": return stats.bestWinStreak >= 10; case "sweeper": return (stats.shutoutWins ?? 0) >= 10; case "ruler": return (stats.hakemRounds ?? 0) >= 50; case "champion": return rating >= tierById("gold").floor; case "platinum_star": return rating >= tierById("platinum").floor; case "leader": return stats.wins >= 250; case "diamond_ace": return rating >= tierById("diamond").floor; case "immortal": return level >= 50; case "the_one": return stats.wins >= 500; case "legend": return rating >= tierById("master").floor; case "sultan": return stats.kotsFor >= 100; case "emperor": return level >= 75; case "grandmaster": return rating >= 2100; default: return false; } } /* ---------------------------- Card styles ---------------------------- */ // Card BACKS (pattern on the reverse of every card). export const CARD_BACKS: CardBackDef[] = [ { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true, pattern: "stripes" }, { id: "midnight", nameFa: "نیمه‌شب", nameEn: "Midnight", c1: "#1b2540", c2: "#0a0f1f", accent: "#8aa0c8", price: 1200, pattern: "grid" }, { id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800, pattern: "dots" }, { id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000, pattern: "argyle" }, { id: "jade", nameFa: "یشم", nameEn: "Jade", c1: "#136f63", c2: "#08221e", accent: "#7fe3c0", price: 2000, pattern: "scales" }, { id: "onyx", nameFa: "اونیکس", nameEn: "Onyx", c1: "#26262b", c2: "#0c0c10", accent: "#b0b0c0", price: 1500, pattern: "crosshatch" }, // Rank/achievement gated — always buyable with coins once the gate is met. { id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 1000, reqAchievement: "wins_25", pattern: "rays" }, { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 1600, reqRating: 1300, pattern: "argyle", motif: "♦" }, { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 2000, reqAchievement: "wins_50", pattern: "royal", motif: "♛" }, { id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 2200, reqRating: 1500, pattern: "rays" }, { id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 2600, reqRating: 1700, pattern: "crosshatch", motif: "✦" }, { id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 3200, reqRating: 1900, reqAchievement: "hakem_7", pattern: "royal", motif: "♔" }, // ✨ Luxury card backs — premium purchasable, each a distinct fancy motif { id: "diamond", nameFa: "الماس", nameEn: "Diamond", c1: "#1a3a55", c2: "#0a1a2e", accent: "#9fe6ff", price: 2800, pattern: "gem", motif: "◆" }, { id: "blackgold", nameFa: "طلای سیاه", nameEn: "Black Gold", c1: "#1a1407", c2: "#000000", accent: "#ffd76a", price: 3500, pattern: "filigree", motif: "♠" }, { id: "platinum-back", nameFa: "پلاتین", nameEn: "Platinum", c1: "#3a3f4a", c2: "#15171c", accent: "#e6ebf2", price: 4200, pattern: "royal", motif: "✦" }, { id: "peacock-back", nameFa: "طاووس", nameEn: "Peacock", c1: "#0a3a52", c2: "#06202e", accent: "#16d3c0", price: 3000, pattern: "scales", motif: "❖" }, { id: "rosegold-back", nameFa: "رزگلد", nameEn: "Rose Gold", c1: "#5a2438", c2: "#2a0e1c", accent: "#ffb0c4", price: 3200, pattern: "argyle", motif: "♥" }, ]; // Card FRONTS (the face background/border behind the suit + rank). export const CARD_FRONTS: CardFrontDef[] = [ { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", bg1: "#fffdf7", bg2: "#f3ead2", border: "rgba(0,0,0,0.12)", price: 0, default: true }, { id: "ivory", nameFa: "عاج", nameEn: "Ivory", bg1: "#ffffff", bg2: "#eef2f8", border: "#c9ccd6", price: 600 }, { id: "sunset", nameFa: "غروب", nameEn: "Sunset", bg1: "#fff3e6", bg2: "#ffd9b0", border: "#e0915a", price: 1000 }, { id: "rosegold", nameFa: "رزگلد", nameEn: "Rose Gold", bg1: "#fff1ee", bg2: "#f6d9cf", border: "#d98a72", price: 900 }, { id: "velvet", nameFa: "مخمل", nameEn: "Velvet", bg1: "#f4ecff", bg2: "#dcc9f5", border: "#9a6fd0", price: 1800 }, { id: "onyx-face", nameFa: "شب‌رنگ", nameEn: "Onyx", bg1: "#2a2a31", bg2: "#16161c", border: "#5a5a6a", price: 1200 }, // Rank/achievement gated — always buyable with coins once the gate is met. { id: "parchment", nameFa: "پوست‌نوشت", nameEn: "Parchment", bg1: "#fbf2d8", bg2: "#efd9a3", border: "#caa84a", price: 1400, reqRating: 1300 }, { id: "mint", nameFa: "نعنایی", nameEn: "Mint", bg1: "#f0fff8", bg2: "#d3f3e3", border: "#57c79a", price: 1600, reqAchievement: "wins_50" }, { id: "goldleaf", nameFa: "زرورق", nameEn: "Gold Leaf", bg1: "#fff7df", bg2: "#f2dd9b", border: "#caa53a", price: 2000, reqRating: 1500 }, { id: "crystal", nameFa: "بلور", nameEn: "Crystal", bg1: "#eefcff", bg2: "#cdeefa", border: "#5fb6d6", price: 2400, reqRating: 1700 }, { id: "imperial-face", nameFa: "شاهانه", nameEn: "Imperial", bg1: "#fff4cf", bg2: "#ecc873", border: "#b8862a", price: 2800, reqAchievement: "wins_100" }, // ✨ Luxury card fronts — premium purchasable { id: "diamond-face", nameFa: "الماس", nameEn: "Diamond", bg1: "#f4fdff", bg2: "#d7f0fb", border: "#7fc6e6", price: 2500 }, { id: "blackgold-face", nameFa: "طلای سیاه", nameEn: "Black Gold", bg1: "#2a2410", bg2: "#14110a", border: "#caa53a", price: 3200 }, ]; /* ------------------- Gated gift catalogue (titles + backs) ------------------- * Purchasable gifts that are LOCKED until a level/rating gate is met (the tier is * encoded in the id `-t-` so the server enforces it generically). Gift avatars * live in types.ts (same scheme). ~100 new gifts total across the three kinds. */ function giftReq(tier: number) { const t = GIFT_TIERS[tier] ?? GIFT_TIERS[1]; return { price: t.price, reqLevel: t.level || undefined, reqRating: t.rating || undefined }; } // 35 gift titles (fa, en), spread across the 5 tiers. const GIFT_TITLE_WORDS: [string, string][] = [ ["سردار", "Commander"], ["یل", "Brave"], ["پهلوان", "Hero"], ["شیردل", "Lionheart"], ["تیزهوش", "Sharp"], ["زبده", "Ace"], ["کارکشته", "Veteran"], ["نخبه", "Elite"], ["بی‌رقیب", "Unrivaled"], ["شکست‌ناپذیر", "Invincible"], ["آتشین", "Fiery"], ["طوفان", "Storm"], ["صاعقه", "Thunder"], ["کولاک", "Blizzard"], ["تاجدار", "Crowned"], ["فرمانروا", "Ruler"], ["شاهباز", "Royal Falcon"], ["گرگ تنها", "Lone Wolf"], ["عقاب", "Eagle"], ["ققنوس", "Phoenix"], ["محاسب", "Tactician"], ["نقشه‌کش", "Strategist"], ["بازی‌ساز", "Playmaker"], ["پادشاه میز", "Table King"], ["حکم‌ران", "Hokm Lord"], ["برگ‌برنده", "Trump Card"], ["دست‌مریزاد", "Masterstroke"], ["افسانه‌ساز", "Legend-Maker"], ["جواهر", "Gem"], ["الماس", "Diamond"], ["زرین", "Golden"], ["شاهنشاه", "Emperor"], ["اسطوره زنده", "Living Legend"], ["استاد اعظم", "Grandmaster"], ["تسخیرناپذیر", "Untamed"], ]; TITLES.push( ...GIFT_TITLE_WORDS.map(([fa, en], i) => { const tier = Math.min(5, Math.floor(i / 7) + 1); const r = giftReq(tier); return { id: `t-g-t${tier}-${i + 1}`, nameFa: fa, nameEn: en, hintFa: "گیفت ویژه", hintEn: "Special gift", price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating }; }) ); // 20 gift card backs (palette × pattern), spread across the 5 tiers. const GIFT_BACK_PALETTE: { fa: string; en: string; c1: string; c2: string; accent: string; pattern: CardBackDef["pattern"]; motif?: string }[] = [ { fa: "نیلی", en: "Indigo", c1: "#2b2f7a", c2: "#10112e", accent: "#8aa0ff", pattern: "stripes" }, { fa: "زمردین", en: "Verdant", c1: "#0d5a44", c2: "#06231a", accent: "#4fe0a8", pattern: "dots" }, { fa: "غروب", en: "Sunset", c1: "#7a3b12", c2: "#2a1407", accent: "#ffb066", pattern: "rays" }, { fa: "بنفشه", en: "Violet", c1: "#532a7a", c2: "#1d0e2e", accent: "#c79cff", pattern: "argyle", motif: "✦" }, { fa: "فیروزه", en: "Turquoise", c1: "#0e5d6e", c2: "#06232a", accent: "#5fe0e0", pattern: "scales" }, { fa: "گلگون", en: "Rose", c1: "#7a2340", c2: "#2a0c16", accent: "#ff8ab0", pattern: "grid" }, { fa: "شنی", en: "Sand", c1: "#7a5a1a", c2: "#2a1e08", accent: "#e6c66a", pattern: "crosshatch" }, { fa: "یخی", en: "Frost", c1: "#3a5a7a", c2: "#13202e", accent: "#9fd0ff", pattern: "dots" }, { fa: "ارغوان", en: "Magenta", c1: "#7a1f5a", c2: "#2a0a1e", accent: "#ff8ae0", pattern: "rays" }, { fa: "جنگلی", en: "Forest", c1: "#2e5a1a", c2: "#0e2008", accent: "#9ee06a", pattern: "stripes" }, { fa: "نقره‌ای", en: "Silver", c1: "#4a4f5a", c2: "#16181d", accent: "#cfd6e6", pattern: "argyle", motif: "♢" }, { fa: "مسی", en: "Copper", c1: "#7a3f1a", c2: "#2a1508", accent: "#e6975a", pattern: "scales" }, { fa: "یاقوتی", en: "Garnet", c1: "#6a1326", c2: "#240710", accent: "#ff7a90", pattern: "filigree", motif: "♦" }, { fa: "کبریتی", en: "Sulfur", c1: "#6a6010", c2: "#242008", accent: "#ffe46a", pattern: "grid" }, { fa: "اطلسی", en: "Petunia", c1: "#3a2a7a", c2: "#12102e", accent: "#9a8aff", pattern: "royal", motif: "♛" }, { fa: "زمستانی", en: "Winter", c1: "#1f4a6a", c2: "#0a1a26", accent: "#7fc6ff", pattern: "filigree", motif: "❄" }, { fa: "آتشفشان", en: "Volcano", c1: "#6a1f10", c2: "#240a06", accent: "#ff7a4a", pattern: "rays", motif: "✦" }, { fa: "کهکشان", en: "Galaxy", c1: "#241a4a", c2: "#0a0820", accent: "#a98aff", pattern: "gem", motif: "✧" }, { fa: "زرافشان", en: "Goldspark", c1: "#5a4410", c2: "#241a06", accent: "#ffd76a", pattern: "royal", motif: "♔" }, { fa: "الماسی", en: "Brilliant", c1: "#103a4a", c2: "#06181f", accent: "#7fe6ff", pattern: "gem", motif: "♦" }, ]; CARD_BACKS.push( ...GIFT_BACK_PALETTE.map((b, i) => { const tier = Math.min(5, Math.floor(i / 4) + 1); const r = giftReq(tier); return { id: `cb-g-t${tier}-${i + 1}`, nameFa: b.fa, nameEn: b.en, c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif, price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating }; }) ); export function cardBackById(id: string): CardBackDef { return CARD_BACKS.find((c) => c.id === id) ?? CARD_BACKS[0]; } export function cardFrontById(id: string): CardFrontDef { return CARD_FRONTS.find((c) => c.id === id) ?? CARD_FRONTS[0]; } // Ownership = default items + what the player has bought. Everything else must be // purchased with coins (req* gates the purchase, it never auto-grants). function ownedCosmeticIds( defs: { id: string; default?: boolean }[], _profile: UserProfile, purchased: string[] ): string[] { const ids = new Set(); for (const d of defs) { if (d.default || purchased.includes(d.id)) ids.add(d.id); } return [...ids]; } export function ownedCardBackIds(profile: UserProfile): string[] { return ownedCosmeticIds(CARD_BACKS, profile, profile.ownedCardBacks ?? []); } /** Avatars the player owns (default + purchased). */ export function ownedAvatarIds(profile: UserProfile): string[] { const purchased = profile.ownedAvatars ?? []; const ids = new Set(); for (const a of AVATARS) { if (a.default || purchased.includes(a.id)) ids.add(a.id); } return [...ids]; } export function ownedCardFrontIds(profile: UserProfile): string[] { return ownedCosmeticIds(CARD_FRONTS, profile, profile.ownedCardFronts ?? []); } /* --------------------- Reactions (Sheklak / شکلک) -------------------- */ export const REACTION_PACKS: ReactionPackDef[] = [ { id: "starter", nameFa: "پایه", nameEn: "Starter", reactions: ["👍", "👏", "😂", "😮"], price: 0, default: true }, { id: "emotions", nameFa: "احساسات", nameEn: "Emotions", reactions: ["😎", "😭", "🤯", "🥳", "😍"], price: 600 }, { id: "taunt", nameFa: "طعنه", nameEn: "Taunts", reactions: ["😏", "🤡", "🙄", "😴", "🥱"], price: 900 }, { id: "champion", nameFa: "قهرمان", nameEn: "Champion", reactions: ["👑", "🏆", "💪", "🔥"], price: 1200, reqRating: 1300 }, { id: "legend", nameFa: "اسطوره", nameEn: "Legend", reactions: ["💎", "⚡", "🐐", "🎯"], price: 1800, reqAchievement: "wins_100" }, ]; export function reactionPackById(id: string): ReactionPackDef | undefined { return REACTION_PACKS.find((p) => p.id === id); } /** Which packs the player currently owns (default + purchased). */ export function ownedReactionPackIds(profile: UserProfile): string[] { const purchased = profile.ownedReactionPacks ?? []; const ids = new Set(); for (const p of REACTION_PACKS) { if (p.default || purchased.includes(p.id)) ids.add(p.id); } return [...ids]; } /** Flattened emoji list the player can send. */ export function ownedReactions(profile: UserProfile): string[] { const ids = new Set(ownedReactionPackIds(profile)); return REACTION_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.reactions); } /* ------------------------- Sticker packs ----------------------------- */ export const STICKER_PACKS: StickerPackDef[] = [ { id: "faces", nameFa: "شکلک‌ها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true }, // Achievement-gated — buyable with coins once the "Seven–Zip" (7–0 sweep) is unlocked. { id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 1200, reqAchievement: "shutout_1" }, // Achievement-gated — "100 Wins". { id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700, reqAchievement: "wins_100" }, // Achievement-gated — "25 Kots". { id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900, reqAchievement: "kot_25" }, // Persian-text stamps (کوت! / دمت گرم / باریکلا / آخه؟) — purchasable. { id: "persian-text", nameFa: "متن فارسی", nameEn: "Persian Text", stickers: ["kot-text", "damet-garm", "barikalla", "akhe"], price: 1100 }, // Achievement-gated premium packs (coin + achievement). { id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 1500, reqAchievement: "hakem_7" }, { id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 1500, reqAchievement: "streak_10" }, /* ---- New themed packs: کل‌کل (banter), Persian trends, Hokm/game ---- */ // کل‌کل / تیکه — trash-talk you fling at the table { id: "kolkol", nameFa: "کل‌کل", nameEn: "Banter", stickers: ["sukhti", "yad-begir", "nobate-man", "naz-nakon"], price: 800 }, { id: "tikeh", nameFa: "تیکه‌انداز", nameEn: "Taunts", stickers: ["kojai", "hool-nasho", "didi-goftam", "bendaz-dige"], price: 1000 }, { id: "shakkak", nameFa: "شاکی", nameEn: "Salty", stickers: ["nakon-eddea", "shans-avordi", "biya-bebin", "kart-nadari"], price: 1000 }, // Persian trend phrases / praise { id: "trends", nameFa: "ترندها", nameEn: "Trends", stickers: ["eyval", "torkundi", "gol-kashti", "harf-nadari"], price: 900 }, { id: "tashvigh", nameFa: "تشویق", nameEn: "Cheers", stickers: ["damet-garm-2", "nush-jan", "be-be", "ghorbunet"], price: 700 }, // Hokm / card-game themed { id: "khanevadeh", nameFa: "خانواده خال", nameEn: "Court Cards", stickers: ["tak-khal", "as-del", "shah-khesht", "bibi-gesht"], price: 1200 }, { id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text", "jam-kon", "kish-mat"], price: 1800, reqRating: 1500 }, // Extra emotions { id: "ehsasat", nameFa: "احساسات", nameEn: "Moods", stickers: ["laugh", "shocked", "cry", "smug"], price: 600 }, // Spicy rival banter — coin + achievement gated. { id: "raghib", nameFa: "رقیب", nameEn: "Rivalry", stickers: ["khdahafez", "weak", "clown", "sleep"], price: 1300, reqAchievement: "kot_10" }, ]; export function stickerPackById(id: string): StickerPackDef | undefined { return STICKER_PACKS.find((p) => p.id === id); } export function ownedStickerPackIds(profile: UserProfile): string[] { const purchased = profile.ownedStickerPacks ?? []; const ids = new Set(); for (const p of STICKER_PACKS) { if (p.default || purchased.includes(p.id)) ids.add(p.id); } return [...ids]; } /** Flattened sticker-id list the player can send. */ export function ownedStickers(profile: UserProfile): string[] { const ids = new Set(ownedStickerPackIds(profile)); return STICKER_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.stickers); } /* ---------------------- Apply a match result ------------------------- */ function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats { const wins = stats.wins + (summary.won ? 1 : 0); const losses = stats.losses + (summary.won ? 0 : 1); const currentWinStreak = summary.won ? stats.currentWinStreak + 1 : 0; return { games: stats.games + 1, wins, losses, kotsFor: stats.kotsFor + (summary.kotFor ? 1 : 0), kotsAgainst: stats.kotsAgainst + (summary.kotAgainst ? 1 : 0), tricks: stats.tricks + summary.tricksWon, currentWinStreak, bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak), shutoutWins: (stats.shutoutWins ?? 0) + (summary.won && summary.shutout ? 1 : 0), hakemRounds: (stats.hakemRounds ?? 0) + (summary.hakemRounds ?? 0), roundsWon: (stats.roundsWon ?? 0) + (summary.roundsWon ?? 0), }; } /** * Apply a finished match to a profile. Returns a new profile + a RewardResult * describing every delta for the post-match UI. */ export function applyMatchResult( profile: UserProfile, summary: MatchSummary, oppRating: number ): { profile: UserProfile; reward: RewardResult } { const ratingBefore = profile.rating; const coinsBefore = profile.coins; const levelBefore = profile.level; const rDelta = ratingDelta(summary, profile.rating, oppRating); const ratingAfter = Math.max(0, ratingBefore + rDelta); const cDelta = coinDelta(summary); // Premium (pro) players earn a multiple of XP. const xpGain = Math.round(matchXp(summary) * (profile.plan === "pro" ? PREMIUM_XP_MULT : 1)); const lvl = addXp(profile.level, profile.xp, xpGain); const stats = applyStats(profile.stats, summary); // Evaluate achievements against the new state. const achievements = { ...profile.achievements }; const unlocked = [...profile.unlocked]; const newAchievements: AchievementUnlock[] = []; let achievementCoins = 0; for (const def of ACHIEVEMENTS) { const prog = achievementProgress(def, stats, ratingAfter, lvl.level); achievements[def.id] = prog; if (prog >= def.goal && !unlocked.includes(def.id)) { unlocked.push(def.id); achievementCoins += def.coinReward; newAchievements.push({ id: def.id, nameFa: def.nameFa, nameEn: def.nameEn, icon: def.icon, coinReward: def.coinReward, }); } } const coinsAfter = Math.max(0, coinsBefore + cDelta + achievementCoins); // Titles unlocked by the new state. const ownedTitles = [...(profile.ownedTitles ?? [])]; const newTitles: TitleUnlock[] = []; for (const tdef of TITLES) { if ( titleUnlocked(tdef.id, stats, ratingAfter, lvl.level) && !ownedTitles.includes(tdef.id) ) { ownedTitles.push(tdef.id); newTitles.push({ id: tdef.id, nameFa: tdef.nameFa, nameEn: tdef.nameEn }); } } const leagueBefore = getLeagueInfo(ratingBefore); const leagueAfter = getLeagueInfo(ratingAfter); const tierIndex = (id: RankTierId) => RANK_TIERS.findIndex((t) => t.id === id); const rankValue = (l: LeagueInfo) => tierIndex(l.tier.id) * 10 - (l.division ?? 0); const promoted = rankValue(leagueAfter) > rankValue(leagueBefore); const demoted = rankValue(leagueAfter) < rankValue(leagueBefore); const newProfile: UserProfile = { ...profile, rating: ratingAfter, coins: coinsAfter, level: lvl.level, xp: lvl.xp, stats, achievements, unlocked, ownedTitles, }; const reward: RewardResult = { ratingBefore, ratingAfter, ratingDelta: ratingAfter - ratingBefore, coinsBefore, coinsAfter, coinsDelta: coinsAfter - coinsBefore, xpGained: xpGain, levelBefore, levelAfter: lvl.level, leveledUp: lvl.level > levelBefore, newAchievements, newTitles, promoted, demoted, }; return { profile: newProfile, reward }; } /* --------------------------- Daily reward ---------------------------- */ export const DAILY_REWARDS = [100, 150, 200, 300, 400, 600, 1500]; export function dailyRewardFor(day: number): number { return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100; } /* ----------------------- Profile-completion reward ----------------------- */ /** One-time coin reward for setting your city on the profile. */ export const CITY_REWARD = 500;