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 23:43:21 +03:30
AVATARS ,
2026-06-04 21:47:38 +03:30
AchievementCategoryDef ,
2026-06-04 22:47:36 +03:30
AchievementCategoryId ,
2026-06-04 10:11:00 +03:30
AchievementDef ,
2026-06-04 22:47:36 +03:30
AchievementMetric ,
2026-06-04 10:11:00 +03:30
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-05 10:40:14 +03:30
// Forfeit: the surrendering team loses double the stake; the winner takes the stake.
if ( summary . forfeit ) return summary . won ? summary . stake : - 2 * summary . stake ;
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-05 00:08:19 +03:30
/** 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 : 5000 } ,
{ id : "xp2" , xp : 600 , price : 12000 } ,
{ id : "xp3" , xp : 1500 , price : 25000 } ,
] ;
2026-06-04 10:11:00 +03:30
/* ------------------------------- XP ---------------------------------- */
2026-06-04 23:43:21 +03:30
/** 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. */
2026-06-04 10:11:00 +03:30
export function xpNeededForLevel ( level : number ) : number {
2026-06-04 23:43:21 +03:30
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 ;
2026-06-04 10:11:00 +03:30
}
2026-06-05 00:08:19 +03:30
/** XP multiplier for premium (pro) players. */
export const PREMIUM_XP_MULT = 1.5 ;
2026-06-04 10:11:00 +03:30
export function matchXp ( summary : MatchSummary ) : number {
2026-06-05 10:40:14 +03:30
// Forfeiting (surrendering) earns no XP.
if ( summary . forfeit && ! summary . won ) return 0 ;
2026-06-05 00:08:19 +03:30
// 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 ) ) ;
2026-06-04 10:11:00 +03:30
}
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 ;
2026-06-04 23:43:21 +03:30
while ( lvl < MAX_LEVEL && xp >= xpNeededForLevel ( lvl ) ) {
2026-06-04 10:11:00 +03:30
xp -= xpNeededForLevel ( lvl ) ;
lvl += 1 ;
leveledUp = true ;
}
2026-06-04 23:43:21 +03:30
// At the cap, don't let XP overflow the bar.
if ( lvl >= MAX_LEVEL ) {
lvl = MAX_LEVEL ;
xp = Math . min ( xp , xpNeededForLevel ( MAX_LEVEL ) ) ;
}
2026-06-04 10:11:00 +03:30
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 : "⚡" } ,
2026-06-04 22:47:36 +03:30
{ id : "hakem" , nameFa : "حاکمیت" , nameEn : "Rulership" , icon : "👑" } ,
2026-06-04 21:47:38 +03:30
{ id : "level" , nameFa : "سطح" , nameEn : "Levels" , icon : "⭐" } ,
{ id : "rank" , nameFa : "لیگ" , nameEn : "Ranks" , icon : "🏅" } ,
{ id : "veteran" , nameFa : "کارنامه" , nameEn : "Veterancy" , icon : "🎮" } ,
] ;
2026-06-04 22:47:36 +03:30
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.max ( 100 , Math . round ( ( 80 + g * 12 ) / 50 ) * 50 ) ,
icon ,
nameFa : faName ( faNum ( g ) ) ,
nameEn : enName ( g ) ,
descFa : faDesc ( faNum ( g ) ) ,
descEn : enDesc ( g ) ,
} ) ) ;
}
2026-06-04 10:11:00 +03:30
export const ACHIEVEMENTS : AchievementDef [ ] = [
2026-06-04 22:47:36 +03:30
. . . 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)
2026-06-04 21:47:38 +03:30
{ 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" } ,
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 ;
2026-06-04 22:47:36 +03:30
case "losses" : return stats . losses ;
2026-06-04 21:47:38 +03:30
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 ;
2026-06-04 22:47:36 +03:30
case "hakemRounds" : return stats . hakemRounds ? ? 0 ;
case "roundsWon" : return stats . roundsWon ? ? 0 ;
2026-06-04 21:47:38 +03:30
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 ) ;
}
2026-06-05 09:52:28 +03:30
/**
* 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 ,
} ;
}
2026-06-04 21:47:38 +03:30
/** 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 23:43:21 +03:30
{ 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" } ,
2026-06-04 10:49:54 +03:30
{ id : "champion" , nameFa : "قهرمان" , nameEn : "Champion" , hintFa : "لیگ طلا" , hintEn : "Gold league" } ,
2026-06-04 23:43:21 +03:30
{ id : "platinum_star" , nameFa : "ستاره پلاتین" , nameEn : "Platinum Star" , hintFa : "لیگ پلاتین" , hintEn : "Platinum league" } ,
2026-06-04 21:47:38 +03:30
{ id : "leader" , nameFa : "فرمانده" , nameEn : "Leader" , hintFa : "۲۵۰ برد" , hintEn : "250 wins" } ,
2026-06-04 23:43:21 +03:30
{ 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" } ,
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 23:43:21 +03:30
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 ;
2026-06-04 10:49:54 +03:30
case "champion" :
return rating >= tierById ( "gold" ) . floor ;
2026-06-04 23:43:21 +03:30
case "platinum_star" :
return rating >= tierById ( "platinum" ) . floor ;
2026-06-04 21:47:38 +03:30
case "leader" :
return stats . wins >= 250 ;
2026-06-04 23:43:21 +03:30
case "diamond_ace" :
return rating >= tierById ( "diamond" ) . floor ;
case "immortal" :
return level >= 50 ;
case "the_one" :
return stats . wins >= 500 ;
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 23:43:21 +03:30
{ id : "midnight" , nameFa : "نیمهشب" , nameEn : "Midnight" , c1 : "#1b2540" , c2 : "#0a0f1f" , accent : "#8aa0c8" , price : 1200 } ,
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 23:43:21 +03:30
{ id : "jade" , nameFa : "یشم" , nameEn : "Jade" , c1 : "#136f63" , c2 : "#08221e" , accent : "#7fe3c0" , price : 2000 } ,
{ id : "onyx" , nameFa : "اونیکس" , nameEn : "Onyx" , c1 : "#26262b" , c2 : "#0c0c10" , accent : "#b0b0c0" , price : 1500 } ,
// earned by rank / wins — the higher the rank, the rarer the back
{ id : "crimson" , nameFa : "ارغوانی" , nameEn : "Crimson" , c1 : "#7a1322" , c2 : "#2a0710" , accent : "#ff8a9c" , price : 0 , unlockWins : 25 } ,
{ id : "ruby" , nameFa : "یاقوت" , nameEn : "Ruby" , c1 : "#7f1d2e" , c2 : "#2b0a12" , accent : "#ff7a90" , price : 0 , unlockRating : 1300 } ,
{ id : "royal" , nameFa : "سلطنتی" , nameEn : "Royal" , c1 : "#4a1d7f" , c2 : "#1a0a2e" , accent : "#c77dff" , price : 0 , unlockWins : 50 } ,
{ id : "aurora" , nameFa : "شفق" , nameEn : "Aurora" , c1 : "#1d4e6e" , c2 : "#0a2230" , accent : "#5be0c8" , price : 0 , unlockRating : 1500 } ,
{ id : "obsidian" , nameFa : "ابسیدین" , nameEn : "Obsidian" , c1 : "#101018" , c2 : "#000005" , accent : "#7c5cff" , price : 0 , unlockRating : 1700 } ,
{ id : "imperial" , nameFa : "شاهنشاهی" , nameEn : "Imperial" , c1 : "#5a3c0a" , c2 : "#241704" , accent : "#ffd76a" , price : 0 , unlockRating : 1900 } ,
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 } ,
2026-06-04 23:43:21 +03:30
{ id : "sunset" , nameFa : "غروب" , nameEn : "Sunset" , bg1 : "#fff3e6" , bg2 : "#ffd9b0" , border : "#e0915a" , price : 1000 } ,
2026-06-04 11:49:19 +03:30
{ id : "rosegold" , nameFa : "رزگلد" , nameEn : "Rose Gold" , bg1 : "#fff1ee" , bg2 : "#f6d9cf" , border : "#d98a72" , price : 900 } ,
2026-06-04 23:43:21 +03:30
{ 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 } ,
// earned by rank / wins
{ id : "parchment" , nameFa : "پوستنوشت" , nameEn : "Parchment" , bg1 : "#fbf2d8" , bg2 : "#efd9a3" , border : "#caa84a" , price : 0 , unlockRating : 1300 } ,
{ id : "mint" , nameFa : "نعنایی" , nameEn : "Mint" , bg1 : "#f0fff8" , bg2 : "#d3f3e3" , border : "#57c79a" , price : 0 , unlockWins : 50 } ,
{ id : "goldleaf" , nameFa : "زرورق" , nameEn : "Gold Leaf" , bg1 : "#fff7df" , bg2 : "#f2dd9b" , border : "#caa53a" , price : 0 , unlockRating : 1500 } ,
{ id : "crystal" , nameFa : "بلور" , nameEn : "Crystal" , bg1 : "#eefcff" , bg2 : "#cdeefa" , border : "#5fb6d6" , price : 0 , unlockRating : 1700 } ,
{ id : "imperial-face" , nameFa : "شاهانه" , nameEn : "Imperial" , bg1 : "#fff4cf" , bg2 : "#ecc873" , border : "#b8862a" , price : 0 , unlockWins : 100 } ,
2026-06-04 11:49:19 +03:30
] ;
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 ? ? [ ] ) ;
}
2026-06-04 23:43:21 +03:30
/** Avatars the player owns (default + rank/wins-earned + purchased). */
export function ownedAvatarIds ( profile : UserProfile ) : string [ ] {
const purchased = profile . ownedAvatars ? ? [ ] ;
const ids = new Set < string > ( ) ;
for ( const a of AVATARS ) {
const earned =
( a . unlockRating != null && profile . rating >= a . unlockRating ) ||
( a . unlockWins != null && profile . stats . wins >= a . unlockWins ) ;
if ( a . default || earned || purchased . includes ( a . id ) ) ids . add ( a . id ) ;
}
return [ . . . ids ] ;
}
2026-06-04 11:49:19 +03:30
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 23:43:21 +03:30
// Persian-text stamps (کوت! / دمت گرم / باریکلا / آخه؟) — purchasable.
{ id : "persian-text" , nameFa : "متن فارسی" , nameEn : "Persian Text" , stickers : [ "kot-text" , "damet-garm" , "barikalla" , "akhe" ] , price : 1100 } ,
// Custom packs earned only via achievements / rank.
2026-06-04 22:47:36 +03:30
{ id : "rulership" , nameFa : "حاکمیت" , nameEn : "Rulership" , stickers : [ "crown-gold" , "seven-zip" ] , price : 0 , unlockAchievement : "hakem_7" } ,
{ id : "firestorm" , nameFa : "آتشین" , nameEn : "Firestorm" , stickers : [ "streak-fire" ] , price : 0 , unlockAchievement : "streak_10" } ,
2026-06-04 23:43:21 +03:30
{ id : "victory" , nameFa : "پیروزی" , nameEn : "Victory" , stickers : [ "bardim" , "hokm-text" ] , price : 0 , unlockRating : 1500 } ,
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 22:47:36 +03:30
hakemRounds : ( stats . hakemRounds ? ? 0 ) + ( summary . hakemRounds ? ? 0 ) ,
roundsWon : ( stats . roundsWon ? ? 0 ) + ( summary . roundsWon ? ? 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 ) ;
2026-06-05 00:08:19 +03:30
// Premium (pro) players earn a multiple of XP.
const xpGain = Math . round ( matchXp ( summary ) * ( profile . plan === "pro" ? PREMIUM_XP_MULT : 1 ) ) ;
2026-06-04 10:11:00 +03:30
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 ;
}