2026-06-04 10:11:00 +03:30
// Pure gamification rules: ranks/leagues, rating, XP/levels, coins,
// daily rewards, achievements. No side effects, no storage — unit-testable.
import {
2026-06-04 21:47:38 +03:30
AchievementCategoryDef ,
2026-06-04 10:11:00 +03:30
AchievementDef ,
AchievementUnlock ,
2026-06-04 11:49:19 +03:30
CardBackDef ,
CardFrontDef ,
2026-06-04 10:11:00 +03:30
LeagueInfo ,
2026-06-04 21:47:38 +03:30
MatchLeague ,
2026-06-04 10:11:00 +03:30
MatchSummary ,
PlayerStats ,
RankTier ,
RankTierId ,
2026-06-04 11:02:25 +03:30
ReactionPackDef ,
2026-06-04 10:11:00 +03:30
RewardResult ,
2026-06-04 11:15:28 +03:30
StickerPackDef ,
2026-06-04 10:49:54 +03:30
TitleDef ,
TitleUnlock ,
2026-06-04 10:11:00 +03:30
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 {
2026-06-04 16:28:59 +03:30
// Free games (vs computer / private friend rooms) never touch coins.
if ( ! summary . ranked ) return 0 ;
2026-06-04 21:47:38 +03:30
// 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 ;
2026-06-04 16:28:59 +03:30
return ( summary . won ? summary . stake : - summary . stake ) + kotBonus ;
2026-06-04 10:11:00 +03:30
}
2026-06-04 21:47:38 +03:30
/* ----------------------------- 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 ] ;
}
2026-06-04 10:11:00 +03:30
/* ------------------------------- XP ---------------------------------- */
/** XP required to advance from `level` to `level + 1`. */
export function xpNeededForLevel ( level : number ) : number {
return 100 * level ;
}
export function matchXp ( summary : MatchSummary ) : number {
return (
40 +
( summary . won ? 80 : 0 ) +
summary . tricksWon * 5 +
( summary . kotFor ? 30 : 0 )
) ;
}
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 ( xp >= xpNeededForLevel ( lvl ) ) {
xp -= xpNeededForLevel ( lvl ) ;
lvl += 1 ;
leveledUp = true ;
}
return { level : lvl , xp , leveledUp } ;
}
/* --------------------------- Achievements ---------------------------- */
2026-06-04 21:47:38 +03:30
export const ACHIEVEMENT_CATEGORIES : AchievementCategoryDef [ ] = [
{ id : "victory" , nameFa : "بردها" , nameEn : "Victories" , icon : "🏆" } ,
{ id : "kot" , nameFa : "کُت" , nameEn : "Kot" , icon : "🔥" } ,
{ id : "streak" , nameFa : "نوار پیروزی" , nameEn : "Streaks" , icon : "⚡" } ,
{ id : "level" , nameFa : "سطح" , nameEn : "Levels" , icon : "⭐" } ,
{ id : "rank" , nameFa : "لیگ" , nameEn : "Ranks" , icon : "🏅" } ,
{ id : "veteran" , nameFa : "کارنامه" , nameEn : "Veterancy" , icon : "🎮" } ,
] ;
2026-06-04 10:11:00 +03:30
export const ACHIEVEMENTS : AchievementDef [ ] = [
2026-06-04 21:47:38 +03:30
// ---- Victories (wins + shutouts) ----
{ id : "first_win" , category : "victory" , metric : "wins" , goal : 1 , coinReward : 100 , icon : "🥇" , nameFa : "اولین برد" , nameEn : "First Win" , descFa : "اولین بازی خود را ببرید" , descEn : "Win your first game" } ,
{ id : "wins_10" , category : "victory" , metric : "wins" , goal : 10 , coinReward : 300 , icon : "🎯" , nameFa : "۱۰ برد" , nameEn : "10 Wins" , descFa : "۱۰ بازی ببرید" , descEn : "Win 10 games" } ,
{ id : "wins_25" , category : "victory" , metric : "wins" , goal : 25 , coinReward : 600 , icon : "🏅" , nameFa : "۲۵ برد" , nameEn : "25 Wins" , descFa : "۲۵ بازی ببرید" , descEn : "Win 25 games" } ,
{ id : "wins_50" , category : "victory" , metric : "wins" , goal : 50 , coinReward : 1000 , icon : "🏆" , nameFa : "۵۰ برد" , nameEn : "50 Wins" , descFa : "۵۰ بازی ببرید" , descEn : "Win 50 games" } ,
{ id : "wins_100" , category : "victory" , metric : "wins" , goal : 100 , coinReward : 2000 , icon : "👑" , nameFa : "۱۰۰ برد" , nameEn : "100 Wins" , descFa : "۱۰۰ بازی ببرید (پک استیکر ایرانی)" , descEn : "Win 100 games (unlocks Persian stickers)" } ,
{ id : "wins_250" , category : "victory" , metric : "wins" , goal : 250 , coinReward : 4000 , icon : "💎" , nameFa : "۲۵۰ برد" , nameEn : "250 Wins" , descFa : "۲۵۰ بازی ببرید" , descEn : "Win 250 games" } ,
{ id : "wins_500" , category : "victory" , metric : "wins" , goal : 500 , coinReward : 8000 , icon : "🌟" , nameFa : "۵۰۰ برد" , nameEn : "500 Wins" , descFa : "۵۰۰ بازی ببرید" , descEn : "Win 500 games" } ,
{ id : "shutout_1" , category : "victory" , metric : "shutoutWins" , goal : 1 , coinReward : 400 , icon : "🧹" , nameFa : "هفت–هیچ" , nameEn : "Seven– Zip" , descFa : "بازی را ۷–۰ ببرید (پک استیکر حکم)" , descEn : "Win a match 7– 0 (unlocks Hokm stickers)" } ,
{ id : "shutout_5" , category : "victory" , metric : "shutoutWins" , goal : 5 , coinReward : 900 , icon : "🧨" , nameFa : "۵ بار هفت–هیچ" , nameEn : "5× Sweep" , descFa : "۵ بار حریف را ۷–۰ ببرید" , descEn : "Sweep the opponent 5 times" } ,
{ id : "shutout_25" , category : "victory" , metric : "shutoutWins" , goal : 25 , coinReward : 3000 , icon : "☄️" , nameFa : "۲۵ بار هفت–هیچ" , nameEn : "25× Sweep" , descFa : "۲۵ بار حریف را ۷–۰ ببرید" , descEn : "Sweep the opponent 25 times" } ,
// ---- Kot ----
{ id : "first_kot" , category : "kot" , metric : "kotsFor" , goal : 1 , coinReward : 150 , icon : "🔥" , nameFa : "اولین کُت" , nameEn : "First Kot" , descFa : "یک بار حریف را کُت کنید" , descEn : "Inflict a Kot once" } ,
{ id : "kot_5" , category : "kot" , metric : "kotsFor" , goal : 5 , coinReward : 300 , icon : "🌶️" , nameFa : "۵ کُت" , nameEn : "5 Kots" , descFa : "۵ بار حریف را کُت کنید" , descEn : "Inflict 5 Kots" } ,
{ id : "kot_10" , category : "kot" , metric : "kotsFor" , goal : 10 , coinReward : 500 , icon : "🔥" , nameFa : "۱۰ کُت" , nameEn : "10 Kots" , descFa : "۱۰ بار حریف را کُت کنید" , descEn : "Inflict 10 Kots" } ,
{ id : "kot_25" , category : "kot" , metric : "kotsFor" , goal : 25 , coinReward : 1200 , icon : "💥" , nameFa : "۲۵ کُت" , nameEn : "25 Kots" , descFa : "۲۵ بار حریف را کُت کنید (پک استیکر طعنه)" , descEn : "Inflict 25 Kots (unlocks Taunt stickers)" } ,
{ id : "kot_50" , category : "kot" , metric : "kotsFor" , goal : 50 , coinReward : 2500 , icon : "⚡" , nameFa : "۵۰ کُت" , nameEn : "50 Kots" , descFa : "۵۰ بار حریف را کُت کنید" , descEn : "Inflict 50 Kots" } ,
{ id : "kot_100" , category : "kot" , metric : "kotsFor" , goal : 100 , coinReward : 5000 , icon : "👹" , nameFa : "۱۰۰ کُت" , nameEn : "100 Kots" , descFa : "۱۰۰ بار حریف را کُت کنید" , descEn : "Inflict 100 Kots" } ,
// ---- Streaks ----
{ id : "streak_3" , category : "streak" , metric : "bestWinStreak" , goal : 3 , coinReward : 200 , icon : "➡️" , nameFa : "۳ برد پیاپی" , nameEn : "3 Win Streak" , descFa : "۳ بازی پشت سر هم ببرید" , descEn : "Win 3 games in a row" } ,
{ id : "streak_5" , category : "streak" , metric : "bestWinStreak" , goal : 5 , coinReward : 400 , icon : "⚡" , nameFa : "۵ برد پیاپی" , nameEn : "5 Win Streak" , descFa : "۵ بازی پشت سر هم ببرید" , descEn : "Win 5 games in a row" } ,
{ id : "streak_10" , category : "streak" , metric : "bestWinStreak" , goal : 10 , coinReward : 1000 , icon : "🌊" , nameFa : "۱۰ برد پیاپی" , nameEn : "10 Win Streak" , descFa : "۱۰ بازی پشت سر هم ببرید" , descEn : "Win 10 games in a row" } ,
{ id : "streak_15" , category : "streak" , metric : "bestWinStreak" , goal : 15 , coinReward : 2000 , icon : "🚀" , nameFa : "۱۵ برد پیاپی" , nameEn : "15 Win Streak" , descFa : "۱۵ بازی پشت سر هم ببرید" , descEn : "Win 15 games in a row" } ,
// ---- Levels (every 5) ----
{ id : "level_5" , category : "level" , metric : "level" , goal : 5 , coinReward : 150 , icon : "⭐" , nameFa : "سطح ۵ " , nameEn : "Level 5" , descFa : "به سطح ۵ برسید" , descEn : "Reach level 5" } ,
{ id : "level_10" , category : "level" , metric : "level" , goal : 10 , coinReward : 300 , icon : "🌟" , nameFa : "سطح ۱۰" , nameEn : "Level 10" , descFa : "به سطح ۱۰ برسید" , descEn : "Reach level 10" } ,
{ id : "level_15" , category : "level" , metric : "level" , goal : 15 , coinReward : 500 , icon : "✨" , nameFa : "سطح ۱۵" , nameEn : "Level 15" , descFa : "به سطح ۱۵ برسید" , descEn : "Reach level 15" } ,
{ id : "level_20" , category : "level" , metric : "level" , goal : 20 , coinReward : 800 , icon : "💫" , nameFa : "سطح ۲۰" , nameEn : "Level 20" , descFa : "به سطح ۲۰ برسید" , descEn : "Reach level 20" } ,
{ id : "level_25" , category : "level" , metric : "level" , goal : 25 , coinReward : 1200 , icon : "🔆" , nameFa : "سطح ۲۵" , nameEn : "Level 25" , descFa : "به سطح ۲۵ برسید (آپلود عکس باز میشود)" , descEn : "Reach level 25 (unlocks photo upload)" } ,
{ id : "level_30" , category : "level" , metric : "level" , goal : 30 , coinReward : 1600 , icon : "🎖️" , nameFa : "سطح ۳۰" , nameEn : "Level 30" , descFa : "به سطح ۳۰ برسید" , descEn : "Reach level 30" } ,
{ id : "level_40" , category : "level" , metric : "level" , goal : 40 , coinReward : 2500 , icon : "🏵️" , nameFa : "سطح ۴۰" , nameEn : "Level 40" , descFa : "به سطح ۴۰ برسید" , descEn : "Reach level 40" } ,
{ id : "level_50" , category : "level" , metric : "level" , goal : 50 , coinReward : 4000 , icon : "🌠" , nameFa : "سطح ۵۰" , nameEn : "Level 50" , descFa : "به سطح ۵۰ برسید" , descEn : "Reach level 50" } ,
// ---- Ranks ----
{ id : "reach_silver" , category : "rank" , ratingFloor : 1100 , goal : 1 , coinReward : 200 , icon : "🥈" , nameFa : "لیگ نقره" , nameEn : "Reach Silver" , descFa : "به لیگ نقره برسید" , descEn : "Reach the Silver league" } ,
{ id : "reach_gold" , category : "rank" , ratingFloor : 1300 , goal : 1 , coinReward : 500 , icon : "🥇" , nameFa : "لیگ طلا" , nameEn : "Reach Gold" , descFa : "به لیگ طلا برسید" , descEn : "Reach the Gold league" } ,
{ id : "reach_platinum" , category : "rank" , ratingFloor : 1500 , goal : 1 , coinReward : 1000 , icon : "🛡️" , nameFa : "لیگ پلاتین" , nameEn : "Reach Platinum" , descFa : "به لیگ پلاتین برسید" , descEn : "Reach the Platinum league" } ,
{ id : "reach_diamond" , category : "rank" , ratingFloor : 1700 , goal : 1 , coinReward : 2000 , icon : "💠" , nameFa : "لیگ الماس" , nameEn : "Reach Diamond" , descFa : "به لیگ الماس برسید" , descEn : "Reach the Diamond league" } ,
{ id : "reach_master" , category : "rank" , ratingFloor : 1900 , goal : 1 , coinReward : 4000 , icon : "👑" , nameFa : "لیگ استاد" , nameEn : "Reach Master" , descFa : "به لیگ استاد برسید" , descEn : "Reach the Master league" } ,
// ---- Veterancy (games + tricks) ----
{ id : "games_10" , category : "veteran" , metric : "games" , goal : 10 , coinReward : 150 , icon : "🎮" , nameFa : "۱۰ بازی" , nameEn : "10 Games" , descFa : "۱۰ بازی انجام دهید" , descEn : "Play 10 games" } ,
{ id : "games_50" , category : "veteran" , metric : "games" , goal : 50 , coinReward : 350 , icon : "🕹️" , nameFa : "۵۰ بازی" , nameEn : "50 Games" , descFa : "۵۰ بازی انجام دهید" , descEn : "Play 50 games" } ,
{ id : "games_200" , category : "veteran" , metric : "games" , goal : 200 , coinReward : 1200 , icon : "🎲" , nameFa : "۲۰۰ بازی" , nameEn : "200 Games" , descFa : "۲۰۰ بازی انجام دهید" , descEn : "Play 200 games" } ,
{ id : "games_500" , category : "veteran" , metric : "games" , goal : 500 , coinReward : 3000 , icon : "🃏" , nameFa : "۵۰۰ بازی" , nameEn : "500 Games" , descFa : "۵۰۰ بازی انجام دهید" , descEn : "Play 500 games" } ,
{ id : "games_1000" , category : "veteran" , metric : "games" , goal : 1000 , coinReward : 7000 , icon : "♾️" , nameFa : "۱۰۰۰ بازی" , nameEn : "1000 Games" , descFa : "۱۰۰۰ بازی انجام دهید" , descEn : "Play 1000 games" } ,
{ id : "tricks_100" , category : "veteran" , metric : "tricks" , goal : 100 , coinReward : 300 , icon : "🎴" , nameFa : "۱۰۰ دست" , nameEn : "100 Tricks" , descFa : "۱۰۰ دست ببرید" , descEn : "Win 100 tricks" } ,
{ id : "tricks_1000" , category : "veteran" , metric : "tricks" , goal : 1000 , coinReward : 2000 , icon : "🗂️" , nameFa : "۱۰۰۰ دست" , nameEn : "1000 Tricks" , descFa : "۱۰۰۰ دست ببرید" , descEn : "Win 1000 tricks" } ,
2026-06-04 10:11:00 +03:30
] ;
2026-06-04 21:47:38 +03:30
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. */
2026-06-04 10:11:00 +03:30
export function achievementProgress (
2026-06-04 21:47:38 +03:30
def : AchievementDef ,
2026-06-04 10:11:00 +03:30
stats : PlayerStats ,
2026-06-04 21:47:38 +03:30
rating : number ,
level : number
2026-06-04 10:11:00 +03:30
) : number {
2026-06-04 21:47:38 +03:30
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 ) ;
2026-06-04 10:11:00 +03:30
}
2026-06-04 10:49:54 +03:30
/* ------------------------------ Titles ------------------------------- */
export const TITLES : TitleDef [ ] = [
{ id : "novice" , nameFa : "تازهکار" , nameEn : "Novice" , hintFa : "پیشفرض" , hintEn : "Default" } ,
{ id : "winner" , nameFa : "برنده" , nameEn : "Winner" , hintFa : "۱۰ برد" , hintEn : "10 wins" } ,
2026-06-04 21:47:38 +03:30
{ 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" } ,
2026-06-04 10:49:54 +03:30
{ id : "champion" , nameFa : "قهرمان" , nameEn : "Champion" , hintFa : "لیگ طلا" , hintEn : "Gold league" } ,
2026-06-04 21:47:38 +03:30
{ id : "leader" , nameFa : "فرمانده" , nameEn : "Leader" , hintFa : "۲۵۰ برد" , hintEn : "250 wins" } ,
2026-06-04 10:49:54 +03:30
{ id : "legend" , nameFa : "اسطوره" , nameEn : "Legend" , hintFa : "لیگ استاد" , hintEn : "Master league" } ,
] ;
export function titleUnlocked (
id : string ,
stats : PlayerStats ,
rating : number ,
level : number
) : boolean {
switch ( id ) {
case "novice" :
return true ;
case "winner" :
return stats . wins >= 10 ;
2026-06-04 21:47:38 +03:30
case "expert" :
return level >= 25 ;
2026-06-04 10:49:54 +03:30
case "kot_master" :
2026-06-04 21:47:38 +03:30
return stats . kotsFor >= 25 ;
case "professional" :
return stats . wins >= 50 ;
2026-06-04 10:49:54 +03:30
case "veteran" :
2026-06-04 21:47:38 +03:30
return level >= 30 ;
case "captain" :
return stats . wins >= 100 ;
2026-06-04 10:49:54 +03:30
case "champion" :
return rating >= tierById ( "gold" ) . floor ;
2026-06-04 21:47:38 +03:30
case "leader" :
return stats . wins >= 250 ;
2026-06-04 10:49:54 +03:30
case "legend" :
return rating >= tierById ( "master" ) . floor ;
default :
return false ;
}
}
/* ---------------------------- Card styles ---------------------------- */
2026-06-04 11:49:19 +03:30
// 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 } ,
2026-06-04 10:49:54 +03:30
{ id : "sapphire" , nameFa : "یاقوت کبود" , nameEn : "Sapphire" , c1 : "#0b3a82" , c2 : "#06173a" , accent : "#6aa6ff" , price : 800 } ,
{ id : "emerald" , nameFa : "زمرد" , nameEn : "Emerald" , c1 : "#0d6b5e" , c2 : "#062420" , accent : "#2dd4bf" , price : 1000 } ,
2026-06-04 11:49:19 +03:30
{ id : "ruby" , nameFa : "یاقوت" , nameEn : "Ruby" , c1 : "#7f1d2e" , c2 : "#2b0a12" , accent : "#ff7a90" , price : 0 , unlockRating : 1300 } , // earned
{ id : "royal" , nameFa : "سلطنتی" , nameEn : "Royal" , c1 : "#4a1d7f" , c2 : "#1a0a2e" , accent : "#c77dff" , price : 0 , unlockWins : 50 } , // earned
2026-06-04 10:49:54 +03:30
] ;
2026-06-04 11:49:19 +03:30
// 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 : "rosegold" , nameFa : "رزگلد" , nameEn : "Rose Gold" , bg1 : "#fff1ee" , bg2 : "#f6d9cf" , border : "#d98a72" , price : 900 } ,
{ id : "parchment" , nameFa : "پوستنوشت" , nameEn : "Parchment" , bg1 : "#fbf2d8" , bg2 : "#efd9a3" , border : "#caa84a" , price : 0 , unlockRating : 1300 } , // earned
{ id : "mint" , nameFa : "نعنایی" , nameEn : "Mint" , bg1 : "#f0fff8" , bg2 : "#d3f3e3" , border : "#57c79a" , price : 0 , unlockWins : 50 } , // earned
] ;
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 ] ;
}
function ownedCosmeticIds (
defs : { id : string ; price : number ; default ? : boolean ; unlockRating? : number ; unlockWins? : number } [ ] ,
profile : UserProfile ,
purchased : string [ ]
) : string [ ] {
const ids = new Set < string > ( ) ;
for ( const d of defs ) {
const earned =
( d . unlockRating != null && profile . rating >= d . unlockRating ) ||
( d . unlockWins != null && profile . stats . wins >= d . unlockWins ) ;
if ( d . default || earned || purchased . includes ( d . id ) ) ids . add ( d . id ) ;
}
return [ . . . ids ] ;
}
export function ownedCardBackIds ( profile : UserProfile ) : string [ ] {
return ownedCosmeticIds ( CARD_BACKS , profile , profile . ownedCardBacks ? ? [ ] ) ;
}
export function ownedCardFrontIds ( profile : UserProfile ) : string [ ] {
return ownedCosmeticIds ( CARD_FRONTS , profile , profile . ownedCardFronts ? ? [ ] ) ;
2026-06-04 10:49:54 +03:30
}
2026-06-04 11:02:25 +03:30
/* --------------------- 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 : 0 , unlockRating : 1300 } ,
{ id : "legend" , nameFa : "اسطوره" , nameEn : "Legend" , reactions : [ "💎" , "⚡" , "🐐" , "🎯" ] , price : 0 , unlockWins : 100 } ,
] ;
export function reactionPackById ( id : string ) : ReactionPackDef | undefined {
return REACTION_PACKS . find ( ( p ) = > p . id === id ) ;
}
/** Which packs the player currently owns (default + earned + purchased). */
export function ownedReactionPackIds ( profile : UserProfile ) : string [ ] {
const purchased = profile . ownedReactionPacks ? ? [ ] ;
const ids = new Set < string > ( ) ;
for ( const p of REACTION_PACKS ) {
const earned =
( p . unlockRating != null && profile . rating >= p . unlockRating ) ||
( p . unlockWins != null && profile . stats . wins >= p . unlockWins ) ;
if ( p . default || earned || 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 ) ;
}
2026-06-04 11:15:28 +03:30
/* ------------------------- Sticker packs ----------------------------- */
export const STICKER_PACKS : StickerPackDef [ ] = [
{ id : "faces" , nameFa : "شکلکها" , nameEn : "Faces" , stickers : [ "happy" , "sad" , "cool" , "love" , "angry" ] , price : 0 , default : true } ,
2026-06-04 21:47:38 +03:30
// Earned by the "Seven– Zip" (7– 0 sweep) achievement.
{ id : "hokm" , nameFa : "حکم" , nameEn : "Hokm" , stickers : [ "hokm-badge" , "kot-stamp" , "crown" , "ace-spade" ] , price : 0 , unlockAchievement : "shutout_1" } ,
// Earned by the "100 Wins" achievement.
{ id : "persian" , nameFa : "ایرانی" , nameEn : "Persian" , stickers : [ "chai" , "afarin" , "rose" ] , price : 700 , unlockAchievement : "wins_100" } ,
// Earned by the "25 Kots" achievement.
{ id : "taunt" , nameFa : "طعنه" , nameEn : "Taunts" , stickers : [ "clown" , "sleep" , "weak" ] , price : 900 , unlockAchievement : "kot_25" } ,
2026-06-04 11:15:28 +03:30
] ;
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 ? ? [ ] ;
2026-06-04 21:47:38 +03:30
const unlocked = profile . unlocked ? ? [ ] ;
2026-06-04 11:15:28 +03:30
const ids = new Set < string > ( ) ;
for ( const p of STICKER_PACKS ) {
const earned =
( p . unlockRating != null && profile . rating >= p . unlockRating ) ||
2026-06-04 21:47:38 +03:30
( p . unlockWins != null && profile . stats . wins >= p . unlockWins ) ||
( p . unlockAchievement != null && unlocked . includes ( p . unlockAchievement ) ) ;
2026-06-04 11:15:28 +03:30
if ( p . default || earned || 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 ) ;
}
2026-06-04 10:11:00 +03:30
/* ---------------------- 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 ) ,
2026-06-04 21:47:38 +03:30
shutoutWins : ( stats . shutoutWins ? ? 0 ) + ( summary . won && summary . shutout ? 1 : 0 ) ,
2026-06-04 10:11:00 +03:30
} ;
}
/**
* 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 ) ;
const xpGain = matchXp ( summary ) ;
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 ) {
2026-06-04 21:47:38 +03:30
const prog = achievementProgress ( def , stats , ratingAfter , lvl . level ) ;
2026-06-04 10:11:00 +03:30
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 ) ;
2026-06-04 10:49:54 +03:30
// 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 } ) ;
}
}
2026-06-04 10:11:00 +03:30
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 ,
2026-06-04 10:49:54 +03:30
ownedTitles ,
2026-06-04 10:11:00 +03:30
} ;
const reward : RewardResult = {
ratingBefore ,
ratingAfter ,
ratingDelta : ratingAfter - ratingBefore ,
coinsBefore ,
coinsAfter ,
coinsDelta : coinsAfter - coinsBefore ,
xpGained : xpGain ,
levelBefore ,
levelAfter : lvl.level ,
leveledUp : lvl.level > levelBefore ,
newAchievements ,
2026-06-04 10:49:54 +03:30
newTitles ,
2026-06-04 10:11:00 +03:30
promoted ,
demoted ,
} ;
return { profile : newProfile , reward } ;
}
/* --------------------------- Daily reward ---------------------------- */
export const DAILY_REWARDS = [ 100 , 150 , 200 , 300 , 400 , 500 , 1000 ] ;
export function dailyRewardFor ( day : number ) : number {
return DAILY_REWARDS [ Math . min ( day , DAILY_REWARDS . length ) - 1 ] ? ? 100 ;
}