More cosmetics (rank-gated) + steeper level curve capped at 100
CI/CD / CI - API (dotnet build + engine sim) (push) Failing after 1m40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m20s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped

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:
soroush.asadi
2026-06-04 23:43:21 +03:30
parent dfb1deee8c
commit 4199a82c9d
6 changed files with 181 additions and 31 deletions
+84 -11
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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 {