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 {
|
|
|
|
|
|
AchievementDef,
|
|
|
|
|
|
AchievementUnlock,
|
2026-06-04 11:49:19 +03:30
|
|
|
|
CardBackDef,
|
|
|
|
|
|
CardFrontDef,
|
2026-06-04 10:11:00 +03:30
|
|
|
|
LeagueInfo,
|
|
|
|
|
|
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 {
|
|
|
|
|
|
const base = summary.won ? (summary.ranked ? 50 : 25) : 10;
|
|
|
|
|
|
const stakeNet = summary.won ? summary.stake : -summary.stake;
|
|
|
|
|
|
const kotBonus = summary.won && summary.kotFor ? 40 : 0;
|
|
|
|
|
|
return base + stakeNet + kotBonus;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ------------------------------- XP ---------------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
/** XP required to advance from `level` to `level + 1`. */
|
|
|
|
|
|
export function xpNeededForLevel(level: number): number {
|
|
|
|
|
|
return 100 * level;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function matchXp(summary: MatchSummary): number {
|
|
|
|
|
|
return (
|
|
|
|
|
|
40 +
|
|
|
|
|
|
(summary.won ? 80 : 0) +
|
|
|
|
|
|
summary.tricksWon * 5 +
|
|
|
|
|
|
(summary.kotFor ? 30 : 0)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface LevelProgress {
|
|
|
|
|
|
level: number;
|
|
|
|
|
|
xp: number; // xp within the current level
|
|
|
|
|
|
leveledUp: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function addXp(level: number, xpInLevel: number, gained: number): LevelProgress {
|
|
|
|
|
|
let lvl = level;
|
|
|
|
|
|
let xp = xpInLevel + gained;
|
|
|
|
|
|
let leveledUp = false;
|
|
|
|
|
|
while (xp >= xpNeededForLevel(lvl)) {
|
|
|
|
|
|
xp -= xpNeededForLevel(lvl);
|
|
|
|
|
|
lvl += 1;
|
|
|
|
|
|
leveledUp = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { level: lvl, xp, leveledUp };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* --------------------------- Achievements ---------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
export const ACHIEVEMENTS: AchievementDef[] = [
|
|
|
|
|
|
{ id: "first_win", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game", icon: "🥇", goal: 1, coinReward: 100 },
|
|
|
|
|
|
{ id: "first_kot", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "حریف را کُت کنید", descEn: "Inflict a Kot on opponents", icon: "🔥", goal: 1, coinReward: 150 },
|
|
|
|
|
|
{ id: "wins_10", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games", icon: "🎯", goal: 10, coinReward: 300 },
|
|
|
|
|
|
{ id: "wins_100", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید", descEn: "Win 100 games", icon: "👑", goal: 100, coinReward: 2000 },
|
|
|
|
|
|
{ id: "streak_5", nameFa: "نوار ۵ برد", nameEn: "5 Win Streak", descFa: "۵ برد پیاپی", descEn: "Win 5 in a row", icon: "⚡", goal: 5, coinReward: 400 },
|
|
|
|
|
|
{ id: "reach_gold", nameFa: "رسیدن به طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league", icon: "🏅", goal: 1, coinReward: 500 },
|
|
|
|
|
|
{ id: "games_50", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games", icon: "🎮", goal: 50, coinReward: 350 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/** Current raw progress value for an achievement from stats + rating. */
|
|
|
|
|
|
export function achievementProgress(
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
stats: PlayerStats,
|
|
|
|
|
|
rating: number
|
|
|
|
|
|
): number {
|
|
|
|
|
|
switch (id) {
|
|
|
|
|
|
case "first_win":
|
|
|
|
|
|
return Math.min(1, stats.wins);
|
|
|
|
|
|
case "first_kot":
|
|
|
|
|
|
return Math.min(1, stats.kotsFor);
|
|
|
|
|
|
case "wins_10":
|
|
|
|
|
|
return Math.min(10, stats.wins);
|
|
|
|
|
|
case "wins_100":
|
|
|
|
|
|
return Math.min(100, stats.wins);
|
|
|
|
|
|
case "streak_5":
|
|
|
|
|
|
return Math.min(5, stats.bestWinStreak);
|
|
|
|
|
|
case "reach_gold":
|
|
|
|
|
|
return rating >= tierById("gold").floor ? 1 : 0;
|
|
|
|
|
|
case "games_50":
|
|
|
|
|
|
return Math.min(50, stats.games);
|
|
|
|
|
|
default:
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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" },
|
|
|
|
|
|
{ id: "kot_master", nameFa: "استاد کُت", nameEn: "Kot Master", hintFa: "۱۰ کُت", hintEn: "10 kots" },
|
|
|
|
|
|
{ id: "veteran", nameFa: "کهنهکار", nameEn: "Veteran", hintFa: "سطح ۲۰", hintEn: "Level 20" },
|
|
|
|
|
|
{ id: "champion", nameFa: "قهرمان", nameEn: "Champion", hintFa: "لیگ طلا", hintEn: "Gold league" },
|
|
|
|
|
|
{ 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 "kot_master":
|
|
|
|
|
|
return stats.kotsFor >= 10;
|
|
|
|
|
|
case "veteran":
|
|
|
|
|
|
return level >= 20;
|
|
|
|
|
|
case "champion":
|
|
|
|
|
|
return rating >= tierById("gold").floor;
|
|
|
|
|
|
case "legend":
|
|
|
|
|
|
return rating >= tierById("master").floor;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------- Card styles ---------------------------- */
|
|
|
|
|
|
|
2026-06-04 11:49:19 +03:30
|
|
|
|
// Card BACKS (pattern on the reverse of every card).
|
|
|
|
|
|
export const CARD_BACKS: CardBackDef[] = [
|
|
|
|
|
|
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true },
|
2026-06-04 10:49:54 +03:30
|
|
|
|
{ id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800 },
|
|
|
|
|
|
{ id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000 },
|
2026-06-04 11:49:19 +03:30
|
|
|
|
{ id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300 }, // earned
|
|
|
|
|
|
{ id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50 }, // earned
|
2026-06-04 10:49:54 +03:30
|
|
|
|
];
|
|
|
|
|
|
|
2026-06-04 11:49:19 +03:30
|
|
|
|
// Card FRONTS (the face background/border behind the suit + rank).
|
|
|
|
|
|
export const CARD_FRONTS: CardFrontDef[] = [
|
|
|
|
|
|
{ id: "classic", nameFa: "کلاسیک", nameEn: "Classic", bg1: "#fffdf7", bg2: "#f3ead2", border: "rgba(0,0,0,0.12)", price: 0, default: true },
|
|
|
|
|
|
{ id: "ivory", nameFa: "عاج", nameEn: "Ivory", bg1: "#ffffff", bg2: "#eef2f8", border: "#c9ccd6", price: 600 },
|
|
|
|
|
|
{ id: "rosegold", nameFa: "رزگلد", nameEn: "Rose Gold", bg1: "#fff1ee", bg2: "#f6d9cf", border: "#d98a72", price: 900 },
|
|
|
|
|
|
{ id: "parchment", nameFa: "پوستنوشت", nameEn: "Parchment", bg1: "#fbf2d8", bg2: "#efd9a3", border: "#caa84a", price: 0, unlockRating: 1300 }, // earned
|
|
|
|
|
|
{ id: "mint", nameFa: "نعنایی", nameEn: "Mint", bg1: "#f0fff8", bg2: "#d3f3e3", border: "#57c79a", price: 0, unlockWins: 50 }, // earned
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
export function cardBackById(id: string): CardBackDef {
|
|
|
|
|
|
return CARD_BACKS.find((c) => c.id === id) ?? CARD_BACKS[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
export function cardFrontById(id: string): CardFrontDef {
|
|
|
|
|
|
return CARD_FRONTS.find((c) => c.id === id) ?? CARD_FRONTS[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ownedCosmeticIds(
|
|
|
|
|
|
defs: { id: string; price: number; default?: boolean; unlockRating?: number; unlockWins?: number }[],
|
|
|
|
|
|
profile: UserProfile,
|
|
|
|
|
|
purchased: string[]
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const ids = new Set<string>();
|
|
|
|
|
|
for (const d of defs) {
|
|
|
|
|
|
const earned =
|
|
|
|
|
|
(d.unlockRating != null && profile.rating >= d.unlockRating) ||
|
|
|
|
|
|
(d.unlockWins != null && profile.stats.wins >= d.unlockWins);
|
|
|
|
|
|
if (d.default || earned || purchased.includes(d.id)) ids.add(d.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...ids];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function ownedCardBackIds(profile: UserProfile): string[] {
|
|
|
|
|
|
return ownedCosmeticIds(CARD_BACKS, profile, profile.ownedCardBacks ?? []);
|
|
|
|
|
|
}
|
|
|
|
|
|
export function ownedCardFrontIds(profile: UserProfile): string[] {
|
|
|
|
|
|
return ownedCosmeticIds(CARD_FRONTS, profile, profile.ownedCardFronts ?? []);
|
2026-06-04 10:49:54 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 11:02:25 +03:30
|
|
|
|
/* --------------------- Reactions (Sheklak / شکلک) -------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
export const REACTION_PACKS: ReactionPackDef[] = [
|
|
|
|
|
|
{ id: "starter", nameFa: "پایه", nameEn: "Starter", reactions: ["👍", "👏", "😂", "😮"], price: 0, default: true },
|
|
|
|
|
|
{ id: "emotions", nameFa: "احساسات", nameEn: "Emotions", reactions: ["😎", "😭", "🤯", "🥳", "😍"], price: 600 },
|
|
|
|
|
|
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", reactions: ["😏", "🤡", "🙄", "😴", "🥱"], price: 900 },
|
|
|
|
|
|
{ id: "champion", nameFa: "قهرمان", nameEn: "Champion", reactions: ["👑", "🏆", "💪", "🔥"], price: 0, unlockRating: 1300 },
|
|
|
|
|
|
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", reactions: ["💎", "⚡", "🐐", "🎯"], price: 0, unlockWins: 100 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
export function reactionPackById(id: string): ReactionPackDef | undefined {
|
|
|
|
|
|
return REACTION_PACKS.find((p) => p.id === id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Which packs the player currently owns (default + earned + purchased). */
|
|
|
|
|
|
export function ownedReactionPackIds(profile: UserProfile): string[] {
|
|
|
|
|
|
const purchased = profile.ownedReactionPacks ?? [];
|
|
|
|
|
|
const ids = new Set<string>();
|
|
|
|
|
|
for (const p of REACTION_PACKS) {
|
|
|
|
|
|
const earned =
|
|
|
|
|
|
(p.unlockRating != null && profile.rating >= p.unlockRating) ||
|
|
|
|
|
|
(p.unlockWins != null && profile.stats.wins >= p.unlockWins);
|
|
|
|
|
|
if (p.default || earned || purchased.includes(p.id)) ids.add(p.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...ids];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Flattened emoji list the player can send. */
|
|
|
|
|
|
export function ownedReactions(profile: UserProfile): string[] {
|
|
|
|
|
|
const ids = new Set(ownedReactionPackIds(profile));
|
|
|
|
|
|
return REACTION_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.reactions);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 11:15:28 +03:30
|
|
|
|
/* ------------------------- Sticker packs ----------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
export const STICKER_PACKS: StickerPackDef[] = [
|
|
|
|
|
|
{ id: "faces", nameFa: "شکلکها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true },
|
|
|
|
|
|
{ id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 0, unlockRating: 1300 },
|
|
|
|
|
|
{ id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700 },
|
|
|
|
|
|
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
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 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),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Apply a finished match to a profile. Returns a new profile + a RewardResult
|
|
|
|
|
|
* describing every delta for the post-match UI.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function applyMatchResult(
|
|
|
|
|
|
profile: UserProfile,
|
|
|
|
|
|
summary: MatchSummary,
|
|
|
|
|
|
oppRating: number
|
|
|
|
|
|
): { profile: UserProfile; reward: RewardResult } {
|
|
|
|
|
|
const ratingBefore = profile.rating;
|
|
|
|
|
|
const coinsBefore = profile.coins;
|
|
|
|
|
|
const levelBefore = profile.level;
|
|
|
|
|
|
|
|
|
|
|
|
const rDelta = ratingDelta(summary, profile.rating, oppRating);
|
|
|
|
|
|
const ratingAfter = Math.max(0, ratingBefore + rDelta);
|
|
|
|
|
|
|
|
|
|
|
|
const cDelta = coinDelta(summary);
|
|
|
|
|
|
const xpGain = matchXp(summary);
|
|
|
|
|
|
const lvl = addXp(profile.level, profile.xp, xpGain);
|
|
|
|
|
|
|
|
|
|
|
|
const stats = applyStats(profile.stats, summary);
|
|
|
|
|
|
|
|
|
|
|
|
// Evaluate achievements against the new state.
|
|
|
|
|
|
const achievements = { ...profile.achievements };
|
|
|
|
|
|
const unlocked = [...profile.unlocked];
|
|
|
|
|
|
const newAchievements: AchievementUnlock[] = [];
|
|
|
|
|
|
let achievementCoins = 0;
|
|
|
|
|
|
for (const def of ACHIEVEMENTS) {
|
|
|
|
|
|
const prog = achievementProgress(def.id, stats, ratingAfter);
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|