More cosmetics (rank-gated) + steeper level curve capped at 100
Cosmetics — many new variants, the rarer ones gated behind higher ranks: - Card backs: +midnight/jade/onyx (buy) + crimson/aurora/obsidian/imperial (earned by wins/rating up to Master). Card fronts: +sunset/velvet/onyx (buy) + goldleaf/crystal/imperial (earned). - Titles: +marksman, untouchable, sweeper, ruler, platinum_star, diamond_ace, immortal, the_one (gated by kots/streak/shutouts/hakem/rating/level/wins), mirrored on the server so live games grant them. - Avatars: list expanded + rank/wins-earned tier (robot/wizard/ninja/king/ genie/crown) via new ownedAvatarIds(); profile picker shows earned ones, shop sells the priced ones. - Stickers: new Persian-text stamp pack (کوت! / دمت گرم / باریکلا / آخه؟) plus a rank-earned Victory pack (بردیم!/حکم) — new inline-SVG art. Leveling: XP per level now grows (100*l + 15*l²) so each level is harder; higher leagues grant more XP (×1.5 at 500 stake, ×2 at 1000) so you progress by playing up. Hard cap at level 100. Mirrored in server Gamification (XpForLevel/MatchXp/ AddXp). Sim now tops out lower (level 20 vs 35 over 500 matches) as intended. Verified: tsc + next build + dotnet build clean; sim passes; images rebuilt :1500/:1505. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
// daily rewards, achievements. No side effects, no storage — unit-testable.
|
||||
|
||||
import {
|
||||
AVATARS,
|
||||
AchievementCategoryDef,
|
||||
AchievementCategoryId,
|
||||
AchievementDef,
|
||||
@@ -131,18 +132,30 @@ export function leagueById(id: string): MatchLeague {
|
||||
|
||||
/* ------------------------------- XP ---------------------------------- */
|
||||
|
||||
/** XP required to advance from `level` to `level + 1`. */
|
||||
/** 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;
|
||||
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;
|
||||
}
|
||||
|
||||
export function matchXp(summary: MatchSummary): number {
|
||||
return (
|
||||
const base =
|
||||
40 +
|
||||
(summary.won ? 80 : 0) +
|
||||
summary.tricksWon * 5 +
|
||||
(summary.kotFor ? 30 : 0)
|
||||
);
|
||||
(summary.kotFor ? 30 : 0);
|
||||
return Math.round(base * leagueXpFactor(summary.stake));
|
||||
}
|
||||
|
||||
export interface LevelProgress {
|
||||
@@ -155,11 +168,16 @@ export function addXp(level: number, xpInLevel: number, gained: number): LevelPr
|
||||
let lvl = level;
|
||||
let xp = xpInLevel + gained;
|
||||
let leveledUp = false;
|
||||
while (xp >= xpNeededForLevel(lvl)) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -288,8 +306,16 @@ export const TITLES: TitleDef[] = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
@@ -314,10 +340,26 @@ export function titleUnlocked(
|
||||
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:
|
||||
@@ -330,19 +372,34 @@ export function titleUnlocked(
|
||||
// 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: "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
|
||||
{ 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: "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
|
||||
{ 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 {
|
||||
@@ -370,6 +427,19 @@ function ownedCosmeticIds(
|
||||
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 ?? []);
|
||||
}
|
||||
@@ -417,9 +487,12 @@ export const STICKER_PACKS: StickerPackDef[] = [
|
||||
{ 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.
|
||||
// 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 {
|
||||
|
||||
@@ -839,12 +839,12 @@ export class MockOnlineService implements OnlineService {
|
||||
}
|
||||
|
||||
async getShopItems(): Promise<ShopItem[]> {
|
||||
const avatarItems: ShopItem[] = AVATARS.slice(2).map((a, i) => ({
|
||||
const avatarItems: ShopItem[] = AVATARS.filter((a) => (a.price ?? 0) > 0).map((a) => ({
|
||||
id: a.id,
|
||||
kind: "avatar",
|
||||
nameFa: "آواتار",
|
||||
nameEn: "Avatar",
|
||||
price: 500 + i * 150,
|
||||
price: a.price!,
|
||||
preview: a.emoji,
|
||||
}));
|
||||
const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({
|
||||
|
||||
+30
-11
@@ -499,17 +499,36 @@ export interface AppNotification {
|
||||
|
||||
/* ------------------------------ Avatars ------------------------------ */
|
||||
|
||||
export const AVATARS: { id: string; emoji: string }[] = [
|
||||
{ id: "a-fox", emoji: "🦊" },
|
||||
{ id: "a-lion", emoji: "🦁" },
|
||||
{ id: "a-owl", emoji: "🦉" },
|
||||
{ id: "a-tiger", emoji: "🐯" },
|
||||
{ id: "a-panda", emoji: "🐼" },
|
||||
{ id: "a-eagle", emoji: "🦅" },
|
||||
{ id: "a-wolf", emoji: "🐺" },
|
||||
{ id: "a-cat", emoji: "🐱" },
|
||||
{ id: "a-dragon", emoji: "🐲" },
|
||||
{ id: "a-unicorn", emoji: "🦄" },
|
||||
export interface AvatarDef {
|
||||
id: string;
|
||||
emoji: string;
|
||||
price?: number; // >0 → purchasable in the shop
|
||||
default?: boolean; // owned from the start
|
||||
unlockRating?: number; // earned at this rating (better avatars = higher rank)
|
||||
unlockWins?: number;
|
||||
}
|
||||
|
||||
export const AVATARS: AvatarDef[] = [
|
||||
{ id: "a-fox", emoji: "🦊", default: true },
|
||||
{ id: "a-lion", emoji: "🦁", default: true },
|
||||
{ id: "a-owl", emoji: "🦉", price: 400 },
|
||||
{ id: "a-cat", emoji: "🐱", price: 500 },
|
||||
{ id: "a-tiger", emoji: "🐯", price: 500 },
|
||||
{ id: "a-panda", emoji: "🐼", price: 600 },
|
||||
{ id: "a-bear", emoji: "🐻", price: 600 },
|
||||
{ id: "a-eagle", emoji: "🦅", price: 700 },
|
||||
{ id: "a-wolf", emoji: "🐺", price: 700 },
|
||||
{ id: "a-shark", emoji: "🦈", price: 900 },
|
||||
{ id: "a-dragon", emoji: "🐲", price: 1500 },
|
||||
{ id: "a-unicorn", emoji: "🦄", price: 1500 },
|
||||
{ id: "a-peacock", emoji: "🦚", price: 2000 },
|
||||
// earned by rank / wins — the rarer faces sit behind higher ranks
|
||||
{ id: "a-robot", emoji: "🤖", unlockWins: 50 },
|
||||
{ id: "a-wizard", emoji: "🧙", unlockRating: 1300 },
|
||||
{ id: "a-ninja", emoji: "🥷", unlockWins: 100 },
|
||||
{ id: "a-king", emoji: "🤴", unlockRating: 1500 },
|
||||
{ id: "a-genie", emoji: "🧞", unlockRating: 1700 },
|
||||
{ id: "a-crown", emoji: "👑", unlockRating: 1900 },
|
||||
];
|
||||
|
||||
export function avatarEmoji(id: string): string {
|
||||
|
||||
Reference in New Issue
Block a user