Files
HokmPlay/src/lib/online/gamification.ts
T

690 lines
30 KiB
TypeScript
Raw Normal View History

// Pure gamification rules: ranks/leagues, rating, XP/levels, coins,
// daily rewards, achievements. No side effects, no storage — unit-testable.
import {
AVATARS,
AchievementCategoryDef,
AchievementCategoryId,
AchievementDef,
AchievementMetric,
AchievementUnlock,
CardBackDef,
CardFrontDef,
LeagueInfo,
MatchLeague,
MatchSummary,
PlayerStats,
RankTier,
RankTierId,
ReactionPackDef,
RewardResult,
StickerPackDef,
TitleDef,
TitleUnlock,
UserProfile,
} from "./types";
/* ------------------------------- Ranks ------------------------------- */
export const RANK_TIERS: RankTier[] = [
{ id: "bronze", nameFa: "برنز", nameEn: "Bronze", floor: 0, color: "#cd7f32" },
{ id: "silver", nameFa: "نقره", nameEn: "Silver", floor: 1100, color: "#c0c7d0" },
{ id: "gold", nameFa: "طلا", nameEn: "Gold", floor: 1300, color: "#e6b800" },
{ id: "platinum", nameFa: "پلاتین", nameEn: "Platinum", floor: 1500, color: "#46c2c2" },
{ id: "diamond", nameFa: "الماس", nameEn: "Diamond", floor: 1700, color: "#6aa6ff" },
{ id: "master", nameFa: "استاد", nameEn: "Master", floor: 1900, color: "#c77dff" },
];
const ROMAN = ["", "I", "II", "III"];
export function divisionLabel(division: number | null): string {
if (division == null) return "";
return ROMAN[division] ?? "";
}
export function tierById(id: RankTierId): RankTier {
return RANK_TIERS.find((t) => t.id === id) ?? RANK_TIERS[0];
}
export function getLeagueInfo(rating: number): LeagueInfo {
const r = Math.max(0, Math.round(rating));
let idx = 0;
for (let i = 0; i < RANK_TIERS.length; i++) {
if (r >= RANK_TIERS[i].floor) idx = i;
}
const tier = RANK_TIERS[idx];
const isLast = idx === RANK_TIERS.length - 1;
if (isLast) {
return { tier, division: null, rating: r, nextThreshold: null, progress: 1 };
}
const nextTierFloor = RANK_TIERS[idx + 1].floor;
const band = nextTierFloor - tier.floor;
const third = band / 3;
// division 3 (III) is lowest, 1 (I) is highest
const within = r - tier.floor;
let division: number;
let divStart: number;
let divEnd: number;
if (within < third) {
division = 3;
divStart = tier.floor;
divEnd = tier.floor + third;
} else if (within < 2 * third) {
division = 2;
divStart = tier.floor + third;
divEnd = tier.floor + 2 * third;
} else {
division = 1;
divStart = tier.floor + 2 * third;
divEnd = nextTierFloor;
}
const progress = Math.min(1, Math.max(0, (r - divStart) / (divEnd - divStart)));
return { tier, division, rating: r, nextThreshold: Math.round(divEnd), progress };
}
/* ------------------------------ Rating ------------------------------- */
const K_FACTOR = 32;
/** Elo-style rating delta for a ranked match (0 for casual). */
export function ratingDelta(
summary: MatchSummary,
myRating: number,
oppRating: number
): number {
if (!summary.ranked) return 0;
const expected = 1 / (1 + Math.pow(10, (oppRating - myRating) / 400));
const score = summary.won ? 1 : 0;
let delta = K_FACTOR * (score - expected);
if (summary.won && summary.kotFor) delta += 8;
if (!summary.won && summary.kotAgainst) delta -= 8;
const rounded = Math.round(delta);
// never let a win cost rating or a loss gain it
if (summary.won) return Math.max(1, rounded);
return Math.min(-1, rounded);
}
/* ------------------------------- Coins ------------------------------- */
export function coinDelta(summary: MatchSummary): number {
// Free games (vs computer / private friend rooms) never touch coins.
if (!summary.ranked) return 0;
// Ranked: win the stake (+kot bonus scaled to the league), lose the stake.
// Higher leagues stake more, so wins/losses swing bigger.
const kotBonus = summary.won && summary.kotFor ? Math.round(summary.stake * 0.4) : 0;
return (summary.won ? summary.stake : -summary.stake) + kotBonus;
}
/* ----------------------------- Leagues ------------------------------- */
/** Ranked-matchmaking coin entry tiers (the stake you win/lose). */
export const MATCH_LEAGUES: MatchLeague[] = [
{ id: "starter", entry: 100, minLevel: 1, color: "#2dd4bf", icon: "🌱", nameFa: "لیگ شروع", nameEn: "Starter", descFa: "ورود ۱۰۰ سکه — مناسب تازه‌کارها", descEn: "100-coin entry — for newcomers" },
{ id: "pro", entry: 500, minLevel: 10, color: "#e6b800", icon: "⚔️", nameFa: "لیگ حرفه‌ای", nameEn: "Pro", descFa: "ورود ۵۰۰ سکه — برد و باخت بزرگ‌تر", descEn: "500-coin entry — bigger swings" },
{ id: "expert", entry: 1000, minLevel: 20, color: "#c77dff", icon: "👑", nameFa: "لیگ استادان", nameEn: "Expert", descFa: "ورود ۱۰۰۰ سکه — برای حرفه‌ای‌ها", descEn: "1000-coin entry — for the best" },
];
export function leagueById(id: string): MatchLeague {
return MATCH_LEAGUES.find((l) => l.id === id) ?? MATCH_LEAGUES[0];
}
/** Coin-priced XP packs (XP is intentionally expensive). Server-authoritative. */
export const XP_PACKS: { id: string; xp: number; price: number }[] = [
{ id: "xp1", xp: 200, price: 5000 },
{ id: "xp2", xp: 600, price: 12000 },
{ id: "xp3", xp: 1500, price: 25000 },
];
/* ------------------------------- XP ---------------------------------- */
/** Hard level cap. */
export const MAX_LEVEL = 100;
/** XP required to advance from `level` to `level + 1` — grows with level, so
* each level is harder than the last. */
export function xpNeededForLevel(level: number): number {
return 100 * level + 15 * level * level;
}
/** Higher leagues (bigger stake) grant more XP, so high-level players progress
* by playing up rather than grinding the starter league. */
export function leagueXpFactor(stake: number): number {
if (stake >= 1000) return 2;
if (stake >= 500) return 1.5;
return 1;
}
/** XP multiplier for premium (pro) players. */
export const PREMIUM_XP_MULT = 1.5;
export function matchXp(summary: MatchSummary): number {
// Every game grants XP; the winner earns double.
const base = 40 + summary.tricksWon * 5 + (summary.kotFor ? 30 : 0);
return Math.round(base * (summary.won ? 2 : 1) * leagueXpFactor(summary.stake));
}
export interface LevelProgress {
level: number;
xp: number; // xp within the current level
leveledUp: boolean;
}
export function addXp(level: number, xpInLevel: number, gained: number): LevelProgress {
let lvl = level;
let xp = xpInLevel + gained;
let leveledUp = false;
while (lvl < MAX_LEVEL && xp >= xpNeededForLevel(lvl)) {
xp -= xpNeededForLevel(lvl);
lvl += 1;
leveledUp = true;
}
// At the cap, don't let XP overflow the bar.
if (lvl >= MAX_LEVEL) {
lvl = MAX_LEVEL;
xp = Math.min(xp, xpNeededForLevel(MAX_LEVEL));
}
return { level: lvl, xp, leveledUp };
}
/* --------------------------- Achievements ---------------------------- */
export const ACHIEVEMENT_CATEGORIES: AchievementCategoryDef[] = [
{ id: "victory", nameFa: "بردها", nameEn: "Victories", icon: "🏆" },
{ id: "kot", nameFa: "کُت", nameEn: "Kot", icon: "🔥" },
{ id: "streak", nameFa: "نوار پیروزی", nameEn: "Streaks", icon: "⚡" },
{ id: "hakem", nameFa: "حاکمیت", nameEn: "Rulership", icon: "👑" },
{ id: "level", nameFa: "سطح", nameEn: "Levels", icon: "⭐" },
{ id: "rank", nameFa: "لیگ", nameEn: "Ranks", icon: "🏅" },
{ id: "veteran", nameFa: "کارنامه", nameEn: "Veterancy", icon: "🎮" },
];
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
/** Western → Persian digits, for generated achievement names. */
export function faNum(n: number): string {
return String(n).replace(/\d/g, (d) => FA_DIGITS[+d]);
}
/** Build a tiered family of achievements for one metric (keeps the list DRY). */
function tier(
category: AchievementCategoryId,
metric: AchievementMetric,
prefix: string,
icon: string,
goals: number[],
faName: (g: string) => string,
enName: (g: number) => string,
faDesc: (g: string) => string,
enDesc: (g: number) => string
): AchievementDef[] {
return goals.map((g) => ({
id: `${prefix}_${g}`,
category,
metric,
goal: g,
coinReward: Math.max(100, Math.round((80 + g * 12) / 50) * 50),
icon,
nameFa: faName(faNum(g)),
nameEn: enName(g),
descFa: faDesc(faNum(g)),
descEn: enDesc(g),
}));
}
export const ACHIEVEMENTS: AchievementDef[] = [
...tier("victory", "wins", "wins", "🏆",
[1, 5, 10, 25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 2000],
(g) => `${g} برد`, (g) => `${g} Wins`, (g) => `${g} بازی ببرید`, (g) => `Win ${g} games`),
...tier("victory", "shutoutWins", "shutout", "🧹", [1, 3, 5, 10, 25, 50, 100],
(g) => `${g} بار هفت–هیچ`, (g) => `${g}× Sweep`,
(g) => `${g} بار حریف را ۷–۰ ببرید`, (g) => `Sweep the opponent ${g}×`),
...tier("kot", "kotsFor", "kot", "🔥", [1, 3, 5, 10, 25, 50, 75, 100, 150, 200, 300, 500],
(g) => `${g} کُت`, (g) => `${g} Kots`, (g) => `${g} بار حریف را کُت کنید`, (g) => `Inflict ${g} Kots`),
...tier("streak", "bestWinStreak", "streak", "⚡", [2, 3, 5, 7, 10, 15, 20, 25, 30, 40],
(g) => `${g} برد پیاپی`, (g) => `${g} Win Streak`,
(g) => `${g} بازی پشت سر هم ببرید`, (g) => `Win ${g} games in a row`),
...tier("hakem", "hakemRounds", "hakem", "👑", [7, 25, 50, 100, 250, 500, 1000],
(g) => `${g} بار حاکم`, (g) => `Hakem ${g}×`,
(g) => `${g} دست حاکم شوید`, (g) => `Be the hakem in ${g} rounds`),
...tier("level", "level", "level", "⭐",
[5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100],
(g) => `سطح ${g}`, (g) => `Level ${g}`, (g) => `به سطح ${g} برسید`, (g) => `Reach level ${g}`),
...tier("veteran", "games", "games", "🎮", [10, 25, 50, 100, 200, 300, 500, 750, 1000, 2000, 5000],
(g) => `${g} بازی`, (g) => `${g} Games`, (g) => `${g} بازی انجام دهید`, (g) => `Play ${g} games`),
...tier("veteran", "roundsWon", "rounds", "🎴", [25, 100, 250, 500, 1000, 2000, 5000],
(g) => `${g} دست برده`, (g) => `${g} Rounds Won`, (g) => `${g} دست ببرید`, (g) => `Win ${g} rounds`),
...tier("veteran", "tricks", "tricks", "🗂️", [50, 100, 250, 500, 1000, 2500, 5000, 10000],
(g) => `${g} دست‌برد`, (g) => `${g} Tricks`, (g) => `${g} دست‌برد بگیرید`, (g) => `Win ${g} tricks`),
...tier("veteran", "losses", "grit", "🛡️", [10, 50, 100],
(g) => `${g} باخت`, (g) => `${g} Losses`,
(g) => `با وجود ${g} باخت ادامه دهید`, (g) => `Persevere through ${g} losses`),
// ranks (explicit rating floors)
{ id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 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" },
];
function metricValue(metric: NonNullable<AchievementDef["metric"]>, stats: PlayerStats, level: number): number {
switch (metric) {
case "wins": return stats.wins;
case "losses": return stats.losses;
case "kotsFor": return stats.kotsFor;
case "bestWinStreak": return stats.bestWinStreak;
case "shutoutWins": return stats.shutoutWins ?? 0;
case "games": return stats.games;
case "tricks": return stats.tricks;
case "hakemRounds": return stats.hakemRounds ?? 0;
case "roundsWon": return stats.roundsWon ?? 0;
case "level": return level;
}
}
/** Current raw progress value (0..goal) for an achievement. */
export function achievementProgress(
def: AchievementDef,
stats: PlayerStats,
rating: number,
level: number
): number {
if (def.ratingFloor != null) return rating >= def.ratingFloor ? def.goal : 0;
if (!def.metric) return 0;
return Math.min(def.goal, metricValue(def.metric, stats, level));
}
export function achievementById(id: string): AchievementDef | undefined {
return ACHIEVEMENTS.find((a) => a.id === id);
}
/**
* Re-evaluate all achievements against the profile's current state (used outside
* matches, e.g. after an XP-pack purchase crosses a level milestone). Unlocks new
* ones, grants their coin rewards, and returns the newly-unlocked list.
*/
export function evaluateAchievements(profile: UserProfile): {
profile: UserProfile;
newAchievements: AchievementUnlock[];
} {
const achievements = { ...profile.achievements };
const unlocked = [...profile.unlocked];
const newAchievements: AchievementUnlock[] = [];
let coins = 0;
for (const def of ACHIEVEMENTS) {
const prog = achievementProgress(def, profile.stats, profile.rating, profile.level);
achievements[def.id] = prog;
if (prog >= def.goal && !unlocked.includes(def.id)) {
unlocked.push(def.id);
coins += def.coinReward;
newAchievements.push({
id: def.id,
nameFa: def.nameFa,
nameEn: def.nameEn,
icon: def.icon,
coinReward: def.coinReward,
});
}
}
return {
profile: { ...profile, achievements, unlocked, coins: profile.coins + coins },
newAchievements,
};
}
/** The sticker pack (if any) that unlocking this achievement grants. */
export function stickerPackForAchievement(achId: string): StickerPackDef | undefined {
return STICKER_PACKS.find((p) => p.unlockAchievement === achId);
}
/* ------------------------------ Titles ------------------------------- */
export const TITLES: TitleDef[] = [
{ id: "novice", nameFa: "تازه‌کار", nameEn: "Novice", hintFa: "پیش‌فرض", hintEn: "Default" },
{ id: "winner", nameFa: "برنده", nameEn: "Winner", hintFa: "۱۰ برد", hintEn: "10 wins" },
{ id: "expert", nameFa: "خبره", nameEn: "Expert", hintFa: "سطح ۲۵", hintEn: "Level 25" },
{ id: "kot_master", nameFa: "استاد کُت", nameEn: "Kot Master", hintFa: "۲۵ کُت", hintEn: "25 kots" },
{ id: "professional", nameFa: "حرفه‌ای", nameEn: "Professional", hintFa: "۵۰ برد", hintEn: "50 wins" },
{ id: "veteran", nameFa: "کهنه‌کار", nameEn: "Veteran", hintFa: "سطح ۳۰", hintEn: "Level 30" },
{ id: "captain", nameFa: "کاپیتان", nameEn: "Captain", hintFa: "۱۰۰ برد", hintEn: "100 wins" },
{ id: "marksman", nameFa: "کماندار", nameEn: "Marksman", hintFa: "۵۰ کُت", hintEn: "50 kots" },
{ id: "untouchable", nameFa: "شکست‌ناپذیر", nameEn: "Untouchable", hintFa: "۱۰ برد پیاپی", hintEn: "10 win streak" },
{ id: "sweeper", nameFa: "جاروکش", nameEn: "Sweeper", hintFa: "۱۰ هفت–هیچ", hintEn: "10 sweeps" },
{ id: "ruler", nameFa: "فرمانروا", nameEn: "Ruler", hintFa: "۵۰ بار حاکم", hintEn: "50× hakem" },
{ id: "champion", nameFa: "قهرمان", nameEn: "Champion", hintFa: "لیگ طلا", hintEn: "Gold league" },
{ id: "platinum_star", nameFa: "ستاره پلاتین", nameEn: "Platinum Star", hintFa: "لیگ پلاتین", hintEn: "Platinum league" },
{ id: "leader", nameFa: "فرمانده", nameEn: "Leader", hintFa: "۲۵۰ برد", hintEn: "250 wins" },
{ id: "diamond_ace", nameFa: "آس الماس", nameEn: "Diamond Ace", hintFa: "لیگ الماس", hintEn: "Diamond league" },
{ id: "immortal", nameFa: "جاودانه", nameEn: "Immortal", hintFa: "سطح ۵۰", hintEn: "Level 50" },
{ id: "the_one", nameFa: "یگانه", nameEn: "The One", hintFa: "۵۰۰ برد", hintEn: "500 wins" },
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" },
];
export function titleUnlocked(
id: string,
stats: PlayerStats,
rating: number,
level: number
): boolean {
switch (id) {
case "novice":
return true;
case "winner":
return stats.wins >= 10;
case "expert":
return level >= 25;
case "kot_master":
return stats.kotsFor >= 25;
case "professional":
return stats.wins >= 50;
case "veteran":
return level >= 30;
case "captain":
return stats.wins >= 100;
case "marksman":
return stats.kotsFor >= 50;
case "untouchable":
return stats.bestWinStreak >= 10;
case "sweeper":
return (stats.shutoutWins ?? 0) >= 10;
case "ruler":
return (stats.hakemRounds ?? 0) >= 50;
case "champion":
return rating >= tierById("gold").floor;
case "platinum_star":
return rating >= tierById("platinum").floor;
case "leader":
return stats.wins >= 250;
case "diamond_ace":
return rating >= tierById("diamond").floor;
case "immortal":
return level >= 50;
case "the_one":
return stats.wins >= 500;
case "legend":
return rating >= tierById("master").floor;
default:
return false;
}
}
/* ---------------------------- Card styles ---------------------------- */
// Card BACKS (pattern on the reverse of every card).
export const CARD_BACKS: CardBackDef[] = [
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true },
{ id: "midnight", nameFa: "نیمه‌شب", nameEn: "Midnight", c1: "#1b2540", c2: "#0a0f1f", accent: "#8aa0c8", price: 1200 },
{ 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 },
{ 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 },
];
// Card FRONTS (the face background/border behind the suit + rank).
export const CARD_FRONTS: CardFrontDef[] = [
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", bg1: "#fffdf7", bg2: "#f3ead2", border: "rgba(0,0,0,0.12)", price: 0, default: true },
{ id: "ivory", nameFa: "عاج", nameEn: "Ivory", bg1: "#ffffff", bg2: "#eef2f8", border: "#c9ccd6", price: 600 },
{ id: "sunset", nameFa: "غروب", nameEn: "Sunset", bg1: "#fff3e6", bg2: "#ffd9b0", border: "#e0915a", price: 1000 },
{ id: "rosegold", nameFa: "رزگلد", nameEn: "Rose Gold", bg1: "#fff1ee", bg2: "#f6d9cf", border: "#d98a72", price: 900 },
{ id: "velvet", nameFa: "مخمل", nameEn: "Velvet", bg1: "#f4ecff", bg2: "#dcc9f5", border: "#9a6fd0", price: 1800 },
{ id: "onyx-face", nameFa: "شب‌رنگ", nameEn: "Onyx", bg1: "#2a2a31", bg2: "#16161c", border: "#5a5a6a", price: 1200 },
// 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 },
];
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 ?? []);
}
/** 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];
}
export function ownedCardFrontIds(profile: UserProfile): string[] {
return ownedCosmeticIds(CARD_FRONTS, profile, profile.ownedCardFronts ?? []);
}
/* --------------------- Reactions (Sheklak / شکلک) -------------------- */
export const REACTION_PACKS: ReactionPackDef[] = [
{ id: "starter", nameFa: "پایه", nameEn: "Starter", reactions: ["👍", "👏", "😂", "😮"], price: 0, default: true },
{ id: "emotions", nameFa: "احساسات", nameEn: "Emotions", reactions: ["😎", "😭", "🤯", "🥳", "😍"], price: 600 },
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", reactions: ["😏", "🤡", "🙄", "😴", "🥱"], price: 900 },
{ id: "champion", nameFa: "قهرمان", nameEn: "Champion", reactions: ["👑", "🏆", "💪", "🔥"], price: 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);
}
/* ------------------------- Sticker packs ----------------------------- */
export const STICKER_PACKS: StickerPackDef[] = [
{ id: "faces", nameFa: "شکلک‌ها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true },
// Earned by the "SevenZip" (70 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" },
// 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.
{ 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" },
{ id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text"], price: 0, unlockRating: 1500 },
];
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 unlocked = profile.unlocked ?? [];
const ids = new Set<string>();
for (const p of STICKER_PACKS) {
const earned =
(p.unlockRating != null && profile.rating >= p.unlockRating) ||
(p.unlockWins != null && profile.stats.wins >= p.unlockWins) ||
(p.unlockAchievement != null && unlocked.includes(p.unlockAchievement));
if (p.default || earned || purchased.includes(p.id)) ids.add(p.id);
}
return [...ids];
}
/** Flattened sticker-id list the player can send. */
export function ownedStickers(profile: UserProfile): string[] {
const ids = new Set(ownedStickerPackIds(profile));
return STICKER_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.stickers);
}
/* ---------------------- Apply a match result ------------------------- */
function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
const wins = stats.wins + (summary.won ? 1 : 0);
const losses = stats.losses + (summary.won ? 0 : 1);
const currentWinStreak = summary.won ? stats.currentWinStreak + 1 : 0;
return {
games: stats.games + 1,
wins,
losses,
kotsFor: stats.kotsFor + (summary.kotFor ? 1 : 0),
kotsAgainst: stats.kotsAgainst + (summary.kotAgainst ? 1 : 0),
tricks: stats.tricks + summary.tricksWon,
currentWinStreak,
bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak),
shutoutWins: (stats.shutoutWins ?? 0) + (summary.won && summary.shutout ? 1 : 0),
hakemRounds: (stats.hakemRounds ?? 0) + (summary.hakemRounds ?? 0),
roundsWon: (stats.roundsWon ?? 0) + (summary.roundsWon ?? 0),
};
}
/**
* Apply a finished match to a profile. Returns a new profile + a RewardResult
* describing every delta for the post-match UI.
*/
export function applyMatchResult(
profile: UserProfile,
summary: MatchSummary,
oppRating: number
): { profile: UserProfile; reward: RewardResult } {
const ratingBefore = profile.rating;
const coinsBefore = profile.coins;
const levelBefore = profile.level;
const rDelta = ratingDelta(summary, profile.rating, oppRating);
const ratingAfter = Math.max(0, ratingBefore + rDelta);
const cDelta = coinDelta(summary);
// Premium (pro) players earn a multiple of XP.
const xpGain = Math.round(matchXp(summary) * (profile.plan === "pro" ? PREMIUM_XP_MULT : 1));
const lvl = addXp(profile.level, profile.xp, xpGain);
const stats = applyStats(profile.stats, summary);
// Evaluate achievements against the new state.
const achievements = { ...profile.achievements };
const unlocked = [...profile.unlocked];
const newAchievements: AchievementUnlock[] = [];
let achievementCoins = 0;
for (const def of ACHIEVEMENTS) {
const prog = achievementProgress(def, stats, ratingAfter, lvl.level);
achievements[def.id] = prog;
if (prog >= def.goal && !unlocked.includes(def.id)) {
unlocked.push(def.id);
achievementCoins += def.coinReward;
newAchievements.push({
id: def.id,
nameFa: def.nameFa,
nameEn: def.nameEn,
icon: def.icon,
coinReward: def.coinReward,
});
}
}
const coinsAfter = Math.max(0, coinsBefore + cDelta + achievementCoins);
// Titles unlocked by the new state.
const ownedTitles = [...(profile.ownedTitles ?? [])];
const newTitles: TitleUnlock[] = [];
for (const tdef of TITLES) {
if (
titleUnlocked(tdef.id, stats, ratingAfter, lvl.level) &&
!ownedTitles.includes(tdef.id)
) {
ownedTitles.push(tdef.id);
newTitles.push({ id: tdef.id, nameFa: tdef.nameFa, nameEn: tdef.nameEn });
}
}
const leagueBefore = getLeagueInfo(ratingBefore);
const leagueAfter = getLeagueInfo(ratingAfter);
const tierIndex = (id: RankTierId) => RANK_TIERS.findIndex((t) => t.id === id);
const rankValue = (l: LeagueInfo) =>
tierIndex(l.tier.id) * 10 - (l.division ?? 0);
const promoted = rankValue(leagueAfter) > rankValue(leagueBefore);
const demoted = rankValue(leagueAfter) < rankValue(leagueBefore);
const newProfile: UserProfile = {
...profile,
rating: ratingAfter,
coins: coinsAfter,
level: lvl.level,
xp: lvl.xp,
stats,
achievements,
unlocked,
ownedTitles,
};
const reward: RewardResult = {
ratingBefore,
ratingAfter,
ratingDelta: ratingAfter - ratingBefore,
coinsBefore,
coinsAfter,
coinsDelta: coinsAfter - coinsBefore,
xpGained: xpGain,
levelBefore,
levelAfter: lvl.level,
leveledUp: lvl.level > levelBefore,
newAchievements,
newTitles,
promoted,
demoted,
};
return { profile: newProfile, reward };
}
/* --------------------------- Daily reward ---------------------------- */
export const DAILY_REWARDS = [100, 150, 200, 300, 400, 500, 1000];
export function dailyRewardFor(day: number): number {
return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100;
}