287 lines
9.6 KiB
TypeScript
287 lines
9.6 KiB
TypeScript
|
|
// Pure gamification rules: ranks/leagues, rating, XP/levels, coins,
|
|||
|
|
// daily rewards, achievements. No side effects, no storage — unit-testable.
|
|||
|
|
|
|||
|
|
import {
|
|||
|
|
AchievementDef,
|
|||
|
|
AchievementUnlock,
|
|||
|
|
LeagueInfo,
|
|||
|
|
MatchSummary,
|
|||
|
|
PlayerStats,
|
|||
|
|
RankTier,
|
|||
|
|
RankTierId,
|
|||
|
|
RewardResult,
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------------- 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);
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const reward: RewardResult = {
|
|||
|
|
ratingBefore,
|
|||
|
|
ratingAfter,
|
|||
|
|
ratingDelta: ratingAfter - ratingBefore,
|
|||
|
|
coinsBefore,
|
|||
|
|
coinsAfter,
|
|||
|
|
coinsDelta: coinsAfter - coinsBefore,
|
|||
|
|
xpGained: xpGain,
|
|||
|
|
levelBefore,
|
|||
|
|
levelAfter: lvl.level,
|
|||
|
|
leveledUp: lvl.level > levelBefore,
|
|||
|
|
newAchievements,
|
|||
|
|
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;
|
|||
|
|
}
|