Files
HokmPlay/src/lib/online/gamification.ts
T
soroush.asadi b66e7f77a5
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m20s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped
100+ achievements, forfeit, leagues floor, bot humanize, 95k starter
Achievements: generator-driven, now 100+ across 7 categories (added Rulership)
mirrored client + server with identical ids/goals/coins. New tracked stats:
hakemRounds (be the hakem — incl. "7× Hakem"), roundsWon, plus losses metric.
Custom achievement-only sticker packs (Rulership 👑, Firestorm 🔥) with new
inline-SVG art (crown-gold, seven-zip, streak-fire), unlocked by hakem_7 /
streak_10. Server GameRoom tallies hakem rounds per seat + rounds won per team;
client tallies the same for vs-computer/private games (dealId-deduped).

Forfeit (surrender): a player can request forfeit; if the teammate is a bot it
auto-confirms, otherwise the human teammate gets a confirm/decline prompt
(20s timeout). Result: forfeiting with ≥1 round won = normal loss; 0 rounds = Kot.
Wired client↔server over the hub (RequestForfeit/ConfirmForfeit/DeclineForfeit
+ "forfeit" event); offline/vs-computer ends immediately in the store. Flag
button + confirm dialogs in the table.

Online count: never shows below 50 — live service floors the real count with a
drifting believable number (mock base lowered to ~50–170).

Matchmaking: real players get a longer priority window (9s) before bots fill;
bots now occasionally react after winning a trick (humanize).

Coins: starter pack is 95,000 Toman (50k coins); packs rescaled up (server + mock).

Verified: dotnet build + tsc + next build clean; sim unlocks 57 achievements/500
matches; live server: starter=95000, a 7-hakem win unlocks hakem_7 + wins_1 with
hakemRounds/roundsWon persisted. Images rebuilt on :1500/:1505.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:47:36 +03:30

575 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure gamification rules: ranks/leagues, rating, XP/levels, coins,
// daily rewards, achievements. No side effects, no storage — unit-testable.
import {
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];
}
/* ------------------------------- 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 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);
}
/** 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: "champion", nameFa: "قهرمان", nameEn: "Champion", hintFa: "لیگ طلا", hintEn: "Gold league" },
{ id: "leader", nameFa: "فرمانده", nameEn: "Leader", hintFa: "۲۵۰ برد", hintEn: "250 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 "champion":
return rating >= tierById("gold").floor;
case "leader":
return stats.wins >= 250;
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: "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: "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
];
// 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 ?? []);
}
/* --------------------- 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" },
// Custom packs earned only via achievements.
{ 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" },
];
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);
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, 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;
}