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-07 00:02:28 +03:30
GIFT_TIERS ,
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 } [ ] = [
2026-06-06 21:58:54 +03:30
{ id : "xp1" , xp : 200 , price : 1500 } ,
{ id : "xp2" , xp : 600 , price : 4000 } ,
{ id : "xp3" , xp : 1500 , price : 8000 } ,
2026-06-05 00:08:19 +03:30
] ;
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-06 18:39:24 +03:30
/* ----------------------------- 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 ;
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 ;
}
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 : "⭐" } ,
2026-06-11 13:21:28 +03:30
{ id : "rank" , nameFa : "رتبه" , nameEn : "Ranks" , icon : "🏅" } ,
2026-06-04 21:47:38 +03:30
{ 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 ,
2026-06-06 21:58:54 +03:30
coinReward : Math.min ( 1500 , Math . max ( 50 , Math . round ( ( 40 + g * 6 ) / 50 ) * 50 ) ) ,
2026-06-04 22:47:36 +03:30
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-11 13:21:28 +03:30
{ 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" } ,
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-07 21:27:25 +03:30
/** The sticker pack (if any) that this achievement unlocks for purchase. */
2026-06-04 21:47:38 +03:30
export function stickerPackForAchievement ( achId : string ) : StickerPackDef | undefined {
2026-06-07 21:27:25 +03:30
return STICKER_PACKS . find ( ( p ) = > p . reqAchievement === 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" } ,
2026-06-06 18:39:24 +03:30
// ✨ 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 } ,
2026-06-04 10:49:54 +03:30
] ;
2026-06-06 18:39:24 +03:30
export function titleById ( id : string | null | undefined ) : TitleDef | undefined {
if ( ! id ) return undefined ;
return TITLES . find ( ( t ) = > t . id === id ) ;
}
2026-06-04 10:49:54 +03:30
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 ;
2026-06-06 18:39:24 +03:30
case "sultan" :
return stats . kotsFor >= 100 ;
case "emperor" :
return level >= 75 ;
case "grandmaster" :
return rating >= 2100 ;
2026-06-04 10:49:54 +03:30
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 [ ] = [
2026-06-06 18:39:24 +03:30
{ 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" } ,
2026-06-07 21:27:25 +03:30
// 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 : "♔" } ,
2026-06-06 18:39:24 +03:30
// ✨ 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 : "♥" } ,
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 } ,
2026-06-07 21:27:25 +03:30
// 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" } ,
2026-06-06 18:39:24 +03:30
// ✨ 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 } ,
2026-06-04 11:49:19 +03:30
] ;
2026-06-07 00:02:28 +03:30
/* ------------------- 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<n>-` 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 } ;
} )
) ;
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 ] ;
}
2026-06-07 21:27:25 +03:30
// Ownership = default items + what the player has bought. Everything else must be
// purchased with coins (req* gates the purchase, it never auto-grants).
2026-06-04 11:49:19 +03:30
function ownedCosmeticIds (
2026-06-07 21:27:25 +03:30
defs : { id : string ; default ? : boolean } [ ] ,
_profile : UserProfile ,
2026-06-04 11:49:19 +03:30
purchased : string [ ]
) : string [ ] {
const ids = new Set < string > ( ) ;
for ( const d of defs ) {
2026-06-07 21:27:25 +03:30
if ( d . default || purchased . includes ( d . id ) ) ids . add ( d . id ) ;
2026-06-04 11:49:19 +03:30
}
return [ . . . ids ] ;
}
export function ownedCardBackIds ( profile : UserProfile ) : string [ ] {
return ownedCosmeticIds ( CARD_BACKS , profile , profile . ownedCardBacks ? ? [ ] ) ;
}
2026-06-04 23:43:21 +03:30
2026-06-07 21:27:25 +03:30
/** Avatars the player owns (default + purchased). */
2026-06-04 23:43:21 +03:30
export function ownedAvatarIds ( profile : UserProfile ) : string [ ] {
const purchased = profile . ownedAvatars ? ? [ ] ;
const ids = new Set < string > ( ) ;
for ( const a of AVATARS ) {
2026-06-07 21:27:25 +03:30
if ( a . default || purchased . includes ( a . id ) ) ids . add ( a . id ) ;
2026-06-04 23:43:21 +03:30
}
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 } ,
2026-06-07 21:27:25 +03:30
{ id : "champion" , nameFa : "قهرمان" , nameEn : "Champion" , reactions : [ "👑" , "🏆" , "💪" , "🔥" ] , price : 1200 , reqRating : 1300 } ,
{ id : "legend" , nameFa : "اسطوره" , nameEn : "Legend" , reactions : [ "💎" , "⚡" , "🐐" , "🎯" ] , price : 1800 , reqAchievement : "wins_100" } ,
2026-06-04 11:02:25 +03:30
] ;
export function reactionPackById ( id : string ) : ReactionPackDef | undefined {
return REACTION_PACKS . find ( ( p ) = > p . id === id ) ;
}
2026-06-07 21:27:25 +03:30
/** Which packs the player currently owns (default + purchased). */
2026-06-04 11:02:25 +03:30
export function ownedReactionPackIds ( profile : UserProfile ) : string [ ] {
const purchased = profile . ownedReactionPacks ? ? [ ] ;
const ids = new Set < string > ( ) ;
for ( const p of REACTION_PACKS ) {
2026-06-07 21:27:25 +03:30
if ( p . default || purchased . includes ( p . id ) ) ids . add ( p . id ) ;
2026-06-04 11:02:25 +03:30
}
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-07 21:27:25 +03:30
// 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" } ,
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 } ,
2026-06-07 21:27:25 +03:30
// 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" } ,
2026-06-06 18:39:24 +03:30
/* ---- 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 } ,
2026-06-07 21:27:25 +03:30
{ id : "victory" , nameFa : "پیروزی" , nameEn : "Victory" , stickers : [ "bardim" , "hokm-text" , "jam-kon" , "kish-mat" ] , price : 1800 , reqRating : 1500 } ,
2026-06-06 18:39:24 +03:30
// Extra emotions
{ id : "ehsasat" , nameFa : "احساسات" , nameEn : "Moods" , stickers : [ "laugh" , "shocked" , "cry" , "smug" ] , price : 600 } ,
2026-06-07 21:27:25 +03:30
// Spicy rival banter — coin + achievement gated.
{ id : "raghib" , nameFa : "رقیب" , nameEn : "Rivalry" , stickers : [ "khdahafez" , "weak" , "clown" , "sleep" ] , price : 1300 , reqAchievement : "kot_10" } ,
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 ? ? [ ] ;
const ids = new Set < string > ( ) ;
for ( const p of STICKER_PACKS ) {
2026-06-07 21:27:25 +03:30
if ( p . default || purchased . includes ( p . id ) ) ids . add ( p . id ) ;
2026-06-04 11:15:28 +03:30
}
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 ---------------------------- */
2026-06-06 21:58:54 +03:30
export const DAILY_REWARDS = [ 100 , 150 , 200 , 300 , 400 , 600 , 1500 ] ;
2026-06-04 10:11:00 +03:30
export function dailyRewardFor ( day : number ) : number {
return DAILY_REWARDS [ Math . min ( day , DAILY_REWARDS . length ) - 1 ] ? ? 100 ;
}
2026-06-11 18:11:45 +03:30
/* ----------------------- Profile-completion reward ----------------------- */
/** One-time coin reward for setting your city on the profile. */
export const CITY_REWARD = 500 ;