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
+38
View File
@@ -224,6 +224,44 @@ const STICKERS: Record<string, React.ReactNode> = {
<path d="M50 18 C58 34 70 38 66 56 C64 70 54 78 50 78 C46 78 34 72 34 56 C34 46 42 44 44 36 C50 42 48 50 52 52 C58 50 54 38 50 18 Z" fill="url(#sf)" />
</>
),
/* ---------------------- Persian-text stamps ------------------------- */
"kot-text": (
<>
<rect x="6" y="26" width="88" height="48" rx="10" fill="#7a0f1a" stroke="#ff6b81" strokeWidth="3" transform="rotate(-8 50 50)" />
<text x="50" y="61" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="34" fill="#ffd9de" transform="rotate(-8 50 50)">کوت!</text>
</>
),
"hokm-text": (
<>
<circle cx="50" cy="50" r="42" fill="#13314d" stroke="#d4af37" strokeWidth="3" />
<text x="50" y="62" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="30" fill="#ffe488">حکم</text>
</>
),
"damet-garm": (
<>
<rect x="8" y="30" width="84" height="40" rx="20" fill="#0d6b5e" stroke="#2dd4bf" strokeWidth="3" />
<text x="50" y="57" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="19" fill="#d8fff5">دمت گرم</text>
</>
),
barikalla: (
<>
<circle cx="50" cy="50" r="42" fill="#5a3c0a" stroke="#ffd76a" strokeWidth="3" />
<text x="50" y="58" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="20" fill="#ffe9a8">باریکلا</text>
</>
),
akhe: (
<>
<circle cx="50" cy="50" r="42" fill="#3a2a4d" stroke="#c77dff" strokeWidth="3" />
<text x="50" y="60" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="24" fill="#e7d4ff">آخه؟!</text>
</>
),
bardim: (
<>
<rect x="6" y="28" width="88" height="44" rx="10" fill="#136f3a" stroke="#7fe3a0" strokeWidth="3" transform="rotate(6 50 50)" />
<text x="50" y="59" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="26" fill="#daffe4" transform="rotate(6 50 50)">بردیم!</text>
</>
),
};
export const STICKER_IDS = Object.keys(STICKERS);
+3 -1
View File
@@ -16,6 +16,7 @@ import {
CARD_FRONTS,
TITLES,
achievementProgress,
ownedAvatarIds,
ownedCardBackIds,
ownedCardFrontIds,
} from "@/lib/online/gamification";
@@ -43,6 +44,7 @@ export function ProfileScreen() {
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
const ownedFronts = new Set(ownedCardFrontIds(profile));
const ownedBacks = new Set(ownedCardBackIds(profile));
const ownedAvatars = new Set(ownedAvatarIds(profile));
const saveName = async () => {
if (name.trim()) await updateProfile({ displayName: name.trim() });
@@ -142,7 +144,7 @@ export function ProfileScreen() {
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.chooseAvatar")}</h3>
<div className="flex flex-wrap gap-2">
{AVATARS.filter((a) => profile.ownedAvatars.includes(a.id)).map((a) => (
{AVATARS.filter((a) => ownedAvatars.has(a.id)).map((a) => (
<button
key={a.id}
onClick={() => updateProfile({ avatar: a.id })}