From 4199a82c9d75dd3349f6109179d19bd48e551509 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 23:43:21 +0330 Subject: [PATCH] More cosmetics (rank-gated) + steeper level curve capped at 100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/Hokm.Server/Profiles/Gamification.cs | 30 ++++-- src/components/online/Sticker.tsx | 38 ++++++++ src/components/screens/ProfileScreen.tsx | 4 +- src/lib/online/gamification.ts | 95 ++++++++++++++++--- src/lib/online/mock-service.ts | 4 +- src/lib/online/types.ts | 41 +++++--- 6 files changed, 181 insertions(+), 31 deletions(-) diff --git a/server/src/Hokm.Server/Profiles/Gamification.cs b/server/src/Hokm.Server/Profiles/Gamification.cs index f09237e..92cbecb 100644 --- a/server/src/Hokm.Server/Profiles/Gamification.cs +++ b/server/src/Hokm.Server/Profiles/Gamification.cs @@ -38,9 +38,14 @@ public static class Gamification return (s.Won ? s.Stake : -s.Stake) + kot; } - public static int XpForLevel(int level) => 100 * level; - public static int MatchXp(MatchSummaryDto s) => - 40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0); + public const int MaxLevel = 100; + public static int XpForLevel(int level) => 100 * level + 15 * level * level; + private static double LeagueXpFactor(int stake) => stake >= 1000 ? 2.0 : stake >= 500 ? 1.5 : 1.0; + public static int MatchXp(MatchSummaryDto s) + { + int b = 40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0); + return (int)Math.Round(b * LeagueXpFactor(s.Stake)); + } // metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach. private record AchDef(string Id, string? Metric, int RatingFloor, int Goal, int Coin, string NameFa, string NameEn, string Icon); @@ -102,8 +107,12 @@ public static class Gamification new("novice", "تازه‌کار", "Novice"), new("winner", "برنده", "Winner"), new("expert", "خبره", "Expert"), new("kot_master", "استاد کُت", "Kot Master"), new("professional", "حرفه‌ای", "Professional"), new("veteran", "کهنه‌کار", "Veteran"), - new("captain", "کاپیتان", "Captain"), new("champion", "قهرمان", "Champion"), - new("leader", "فرمانده", "Leader"), new("legend", "اسطوره", "Legend"), + new("captain", "کاپیتان", "Captain"), new("marksman", "کماندار", "Marksman"), + new("untouchable", "شکست‌ناپذیر", "Untouchable"), new("sweeper", "جاروکش", "Sweeper"), + new("ruler", "فرمانروا", "Ruler"), new("champion", "قهرمان", "Champion"), + new("platinum_star", "ستاره پلاتین", "Platinum Star"), new("leader", "فرمانده", "Leader"), + new("diamond_ace", "آس الماس", "Diamond Ace"), new("immortal", "جاودانه", "Immortal"), + new("the_one", "یگانه", "The One"), new("legend", "اسطوره", "Legend"), }; private static bool TitleUnlocked(string id, StatsDto st, int rating, int level) => id switch @@ -115,8 +124,16 @@ public static class Gamification "professional" => st.Wins >= 50, "veteran" => level >= 30, "captain" => st.Wins >= 100, + "marksman" => st.KotsFor >= 50, + "untouchable" => st.BestWinStreak >= 10, + "sweeper" => st.ShutoutWins >= 10, + "ruler" => st.HakemRounds >= 50, "champion" => rating >= 1300, + "platinum_star" => rating >= 1500, "leader" => st.Wins >= 250, + "diamond_ace" => rating >= 1700, + "immortal" => level >= 50, + "the_one" => st.Wins >= 500, "legend" => rating >= 1900, _ => false, }; @@ -125,7 +142,8 @@ public static class Gamification { bool up = false; xp += gain; - while (xp >= XpForLevel(level)) { xp -= XpForLevel(level); level++; up = true; } + while (level < MaxLevel && xp >= XpForLevel(level)) { xp -= XpForLevel(level); level++; up = true; } + if (level >= MaxLevel) { level = MaxLevel; xp = Math.Min(xp, XpForLevel(MaxLevel)); } return (level, xp, up); } diff --git a/src/components/online/Sticker.tsx b/src/components/online/Sticker.tsx index fed863f..da78766 100644 --- a/src/components/online/Sticker.tsx +++ b/src/components/online/Sticker.tsx @@ -224,6 +224,44 @@ const STICKERS: Record = { ), + + /* ---------------------- Persian-text stamps ------------------------- */ + "kot-text": ( + <> + + کوت! + + ), + "hokm-text": ( + <> + + حکم + + ), + "damet-garm": ( + <> + + دمت گرم + + ), + barikalla: ( + <> + + باریکلا + + ), + akhe: ( + <> + + آخه؟! + + ), + bardim: ( + <> + + بردیم! + + ), }; export const STICKER_IDS = Object.keys(STICKERS); diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index ce9d337..3af979c 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -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() {

{t("profile.chooseAvatar")}

- {AVATARS.filter((a) => profile.ownedAvatars.includes(a.id)).map((a) => ( + {AVATARS.filter((a) => ownedAvatars.has(a.id)).map((a) => (