diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index 619f7da..67e9179 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -139,6 +139,47 @@ public class ProfileService return (0, 0); } + // Per-item purchase gates for the named (non-tier-encoded) cosmetics, keyed by + // "kind:id" since ids repeat across kinds (e.g. "taunt" is both a reaction & + // sticker pack). Every one is still coin-priced — this only gates the purchase. + // ⚠️ Mirror of req* in src/lib/online/types.ts (AVATARS) + gamification.ts + // (CARD_BACKS/FRONTS, REACTION_PACKS, STICKER_PACKS). Keep both in sync. + private static readonly Dictionary ItemGate = new() + { + // avatars + ["avatar:a-robot"] = (0, 0, "wins_50"), + ["avatar:a-wizard"] = (0, 1300, null), + ["avatar:a-ninja"] = (0, 0, "wins_100"), + ["avatar:a-king"] = (0, 1500, null), + ["avatar:a-genie"] = (0, 1700, null), + ["avatar:a-crown"] = (0, 1900, "hakem_7"), + ["avatar:a-gem"] = (0, 2100, "shutout_10"), + // card backs + ["cardback:crimson"] = (0, 0, "wins_25"), + ["cardback:ruby"] = (0, 1300, null), + ["cardback:royal"] = (0, 0, "wins_50"), + ["cardback:aurora"] = (0, 1500, null), + ["cardback:obsidian"] = (0, 1700, null), + ["cardback:imperial"] = (0, 1900, "hakem_7"), + // card fronts + ["cardfront:parchment"] = (0, 1300, null), + ["cardfront:mint"] = (0, 0, "wins_50"), + ["cardfront:goldleaf"] = (0, 1500, null), + ["cardfront:crystal"] = (0, 1700, null), + ["cardfront:imperial-face"] = (0, 0, "wins_100"), + // reaction packs + ["reactionpack:champion"] = (0, 1300, null), + ["reactionpack:legend"] = (0, 0, "wins_100"), + // sticker packs + ["stickerpack:hokm"] = (0, 0, "shutout_1"), + ["stickerpack:persian"] = (0, 0, "wins_100"), + ["stickerpack:taunt"] = (0, 0, "kot_25"), + ["stickerpack:rulership"] = (0, 0, "hakem_7"), + ["stickerpack:firestorm"] = (0, 0, "streak_10"), + ["stickerpack:victory"] = (0, 1500, null), + ["stickerpack:raghib"] = (0, 0, "kot_10"), + }; + public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price) { var p = await GetOrCreate(uid, null); @@ -147,6 +188,13 @@ public class ProfileService var gate = GiftGateFor(id); if (p.Level < gate.Level || p.Rating < gate.Rating) return (false, p, "locked"); + // Named-item gate (avatars/backs/fronts/reactions/stickers): coin-priced but + // locked until the level / rating / achievement requirement is met. + if (ItemGate.TryGetValue($"{kind}:{id}", out var ig) && + (p.Level < ig.Level || p.Rating < ig.Rating || + (ig.Ach != null && !p.Unlocked.Contains(ig.Ach)))) + return (false, p, "locked"); + // XP packs are consumable (grant XP, may level up) — not added to an owned list. if (kind == "xp") { diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index c53f081..48c4f9c 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -101,6 +101,11 @@ export function ShopScreen() { const lockLabel = (item: ShopItem): string | null => { if (item.reqLevel && profile.level < item.reqLevel) return `${t("shop.reqLevel")} ${item.reqLevel}`; if (item.reqRating && profile.rating < item.reqRating) return `${t("shop.reqRating")} ${item.reqRating}`; + if (item.reqAchievement && !(profile.unlocked ?? []).includes(item.reqAchievement)) { + const ach = achievementById(item.reqAchievement); + const name = ach ? (locale === "fa" ? ach.nameFa : ach.nameEn) : item.reqAchievement; + return `${t("shop.reqAchv")} ${name}`; + } return null; }; diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 3184b16..5877f85 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -312,6 +312,7 @@ const fa: Dict = { "shop.includes": "شامل", "shop.reqLevel": "سطح", "shop.reqRating": "امتیاز", + "shop.reqAchv": "دستاورد:", "reward.newTitle": "عنوان جدید", "reactions.title": "شکلک", @@ -644,6 +645,7 @@ const en: Dict = { "shop.includes": "Includes", "shop.reqLevel": "Level", "shop.reqRating": "Rating", + "shop.reqAchv": "Achievement:", "reward.newTitle": "New title", "reactions.title": "Emoji", diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index 8f86c06..c7682ff 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -360,9 +360,9 @@ export function evaluateAchievements(profile: UserProfile): { }; } -/** The sticker pack (if any) that unlocking this achievement grants. */ +/** The sticker pack (if any) that this achievement unlocks for purchase. */ export function stickerPackForAchievement(achId: string): StickerPackDef | undefined { - return STICKER_PACKS.find((p) => p.unlockAchievement === achId); + return STICKER_PACKS.find((p) => p.reqAchievement === achId); } /* ------------------------------ Titles ------------------------------- */ @@ -466,13 +466,13 @@ export const CARD_BACKS: CardBackDef[] = [ { id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000, pattern: "argyle" }, { id: "jade", nameFa: "یشم", nameEn: "Jade", c1: "#136f63", c2: "#08221e", accent: "#7fe3c0", price: 2000, pattern: "scales" }, { id: "onyx", nameFa: "اونیکس", nameEn: "Onyx", c1: "#26262b", c2: "#0c0c10", accent: "#b0b0c0", price: 1500, pattern: "crosshatch" }, - // 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, pattern: "rays" }, - { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300, pattern: "argyle", motif: "♦" }, - { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50, pattern: "royal", motif: "♛" }, - { id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 0, unlockRating: 1500, pattern: "rays" }, - { id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 0, unlockRating: 1700, pattern: "crosshatch", motif: "✦" }, - { id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 0, unlockRating: 1900, pattern: "royal", motif: "♔" }, + // Rank/achievement gated — always buyable with coins once the gate is met. + { id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 1000, reqAchievement: "wins_25", pattern: "rays" }, + { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 1600, reqRating: 1300, pattern: "argyle", motif: "♦" }, + { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 2000, reqAchievement: "wins_50", pattern: "royal", motif: "♛" }, + { id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 2200, reqRating: 1500, pattern: "rays" }, + { id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 2600, reqRating: 1700, pattern: "crosshatch", motif: "✦" }, + { id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 3200, reqRating: 1900, reqAchievement: "hakem_7", pattern: "royal", motif: "♔" }, // ✨ Luxury card backs — premium purchasable, each a distinct fancy motif { id: "diamond", nameFa: "الماس", nameEn: "Diamond", c1: "#1a3a55", c2: "#0a1a2e", accent: "#9fe6ff", price: 2800, pattern: "gem", motif: "◆" }, { id: "blackgold", nameFa: "طلای سیاه", nameEn: "Black Gold", c1: "#1a1407", c2: "#000000", accent: "#ffd76a", price: 3500, pattern: "filigree", motif: "♠" }, @@ -489,12 +489,12 @@ export const CARD_FRONTS: CardFrontDef[] = [ { id: "rosegold", nameFa: "رزگلد", nameEn: "Rose Gold", bg1: "#fff1ee", bg2: "#f6d9cf", border: "#d98a72", price: 900 }, { 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 }, + // Rank/achievement gated — always buyable with coins once the gate is met. + { id: "parchment", nameFa: "پوست‌نوشت", nameEn: "Parchment", bg1: "#fbf2d8", bg2: "#efd9a3", border: "#caa84a", price: 1400, reqRating: 1300 }, + { id: "mint", nameFa: "نعنایی", nameEn: "Mint", bg1: "#f0fff8", bg2: "#d3f3e3", border: "#57c79a", price: 1600, reqAchievement: "wins_50" }, + { id: "goldleaf", nameFa: "زرورق", nameEn: "Gold Leaf", bg1: "#fff7df", bg2: "#f2dd9b", border: "#caa53a", price: 2000, reqRating: 1500 }, + { id: "crystal", nameFa: "بلور", nameEn: "Crystal", bg1: "#eefcff", bg2: "#cdeefa", border: "#5fb6d6", price: 2400, reqRating: 1700 }, + { id: "imperial-face", nameFa: "شاهانه", nameEn: "Imperial", bg1: "#fff4cf", bg2: "#ecc873", border: "#b8862a", price: 2800, reqAchievement: "wins_100" }, // ✨ Luxury card fronts — premium purchasable { id: "diamond-face", nameFa: "الماس", nameEn: "Diamond", bg1: "#f4fdff", bg2: "#d7f0fb", border: "#7fc6e6", price: 2500 }, { id: "blackgold-face", nameFa: "طلای سیاه", nameEn: "Black Gold", bg1: "#2a2410", bg2: "#14110a", border: "#caa53a", price: 3200 }, @@ -563,17 +563,16 @@ export function cardFrontById(id: string): CardFrontDef { return CARD_FRONTS.find((c) => c.id === id) ?? CARD_FRONTS[0]; } +// Ownership = default items + what the player has bought. Everything else must be +// purchased with coins (req* gates the purchase, it never auto-grants). function ownedCosmeticIds( - defs: { id: string; price: number; default?: boolean; unlockRating?: number; unlockWins?: number }[], - profile: UserProfile, + defs: { id: string; default?: boolean }[], + _profile: UserProfile, purchased: string[] ): string[] { const ids = new Set(); 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); + if (d.default || purchased.includes(d.id)) ids.add(d.id); } return [...ids]; } @@ -582,15 +581,12 @@ export function ownedCardBackIds(profile: UserProfile): string[] { return ownedCosmeticIds(CARD_BACKS, profile, profile.ownedCardBacks ?? []); } -/** Avatars the player owns (default + rank/wins-earned + purchased). */ +/** Avatars the player owns (default + purchased). */ export function ownedAvatarIds(profile: UserProfile): string[] { const purchased = profile.ownedAvatars ?? []; const ids = new Set(); 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); + if (a.default || purchased.includes(a.id)) ids.add(a.id); } return [...ids]; } @@ -604,23 +600,20 @@ 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 }, + { id: "champion", nameFa: "قهرمان", nameEn: "Champion", reactions: ["👑", "🏆", "💪", "🔥"], price: 1200, reqRating: 1300 }, + { id: "legend", nameFa: "اسطوره", nameEn: "Legend", reactions: ["💎", "⚡", "🐐", "🎯"], price: 1800, reqAchievement: "wins_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). */ +/** Which packs the player currently owns (default + purchased). */ export function ownedReactionPackIds(profile: UserProfile): string[] { const purchased = profile.ownedReactionPacks ?? []; const ids = new Set(); 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); + if (p.default || purchased.includes(p.id)) ids.add(p.id); } return [...ids]; } @@ -635,17 +628,17 @@ export function ownedReactions(profile: UserProfile): string[] { export const STICKER_PACKS: StickerPackDef[] = [ { id: "faces", nameFa: "شکلک‌ها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true }, - // Earned by the "Seven–Zip" (7–0 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" }, + // Achievement-gated — buyable with coins once the "Seven–Zip" (7–0 sweep) is unlocked. + { id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 1200, reqAchievement: "shutout_1" }, + // Achievement-gated — "100 Wins". + { id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700, reqAchievement: "wins_100" }, + // Achievement-gated — "25 Kots". + { id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900, reqAchievement: "kot_25" }, // 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" }, + // Achievement-gated premium packs (coin + achievement). + { id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 1500, reqAchievement: "hakem_7" }, + { id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 1500, reqAchievement: "streak_10" }, /* ---- New themed packs: کل‌کل (banter), Persian trends, Hokm/game ---- */ // کل‌کل / تیکه — trash-talk you fling at the table @@ -657,11 +650,11 @@ export const STICKER_PACKS: StickerPackDef[] = [ { id: "tashvigh", nameFa: "تشویق", nameEn: "Cheers", stickers: ["damet-garm-2", "nush-jan", "be-be", "ghorbunet"], price: 700 }, // Hokm / card-game themed { id: "khanevadeh", nameFa: "خانواده خال", nameEn: "Court Cards", stickers: ["tak-khal", "as-del", "shah-khesht", "bibi-gesht"], price: 1200 }, - { id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text", "jam-kon", "kish-mat"], price: 0, unlockRating: 1500 }, + { id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text", "jam-kon", "kish-mat"], price: 1800, reqRating: 1500 }, // Extra emotions { id: "ehsasat", nameFa: "احساسات", nameEn: "Moods", stickers: ["laugh", "shocked", "cry", "smug"], price: 600 }, - // Mega banter bundle (earned, not sold) — the spicy stuff for rivals - { id: "raghib", nameFa: "رقیب", nameEn: "Rivalry", stickers: ["khdahafez", "weak", "clown", "sleep"], price: 0, unlockAchievement: "kot_10" }, + // Spicy rival banter — coin + achievement gated. + { id: "raghib", nameFa: "رقیب", nameEn: "Rivalry", stickers: ["khdahafez", "weak", "clown", "sleep"], price: 1300, reqAchievement: "kot_10" }, ]; export function stickerPackById(id: string): StickerPackDef | undefined { @@ -670,14 +663,9 @@ export function stickerPackById(id: string): StickerPackDef | undefined { export function ownedStickerPackIds(profile: UserProfile): string[] { const purchased = profile.ownedStickerPacks ?? []; - const unlocked = profile.unlocked ?? []; const ids = new Set(); 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); + if (p.default || purchased.includes(p.id)) ids.add(p.id); } return [...ids]; } diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 916ae7b..2ab0965 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -1036,6 +1036,7 @@ export class MockOnlineService implements OnlineService { descEn: "A legendary profile avatar shown in games & the leaderboard", reqLevel: a.reqLevel, reqRating: a.reqRating, + reqAchievement: a.reqAchievement, }; }); const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({ @@ -1049,6 +1050,7 @@ export class MockOnlineService implements OnlineService { descEn: "The pattern on the back of your cards", reqLevel: c.reqLevel, reqRating: c.reqRating, + reqAchievement: c.reqAchievement, })); const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({ id: c.id, @@ -1059,6 +1061,9 @@ export class MockOnlineService implements OnlineService { preview: c.bg2, descFa: "ظاهر روی کارت‌های شما", descEn: "The face style of your cards", + reqLevel: c.reqLevel, + reqRating: c.reqRating, + reqAchievement: c.reqAchievement, })); const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({ id: r.id, @@ -1070,6 +1075,9 @@ export class MockOnlineService implements OnlineService { contents: r.reactions, descFa: `${faNum(r.reactions.length)} ایموجی برای استفاده در بازی`, descEn: `${r.reactions.length} in-game emotes`, + reqLevel: r.reqLevel, + reqRating: r.reqRating, + reqAchievement: r.reqAchievement, })); const stickerItems: ShopItem[] = STICKER_PACKS.filter((p) => p.price > 0).map((p) => ({ id: p.id, @@ -1081,6 +1089,9 @@ export class MockOnlineService implements OnlineService { contents: p.stickers, descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`, descEn: `${p.stickers.length} in-game stickers`, + reqLevel: p.reqLevel, + reqRating: p.reqRating, + reqAchievement: p.reqAchievement, })); const titleItems: ShopItem[] = TITLES.filter((tt) => (tt.price ?? 0) > 0).map((tt) => ({ id: tt.id, @@ -1114,8 +1125,12 @@ export class MockOnlineService implements OnlineService { const item = items.find((i) => i.id === id); if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" }; - // Purchase gate: locked until the level/rating requirement is met. - if ((item.reqLevel && p.level < item.reqLevel) || (item.reqRating && p.rating < item.reqRating)) + // Purchase gate: locked until the level / rating / achievement requirement is met. + if ( + (item.reqLevel && p.level < item.reqLevel) || + (item.reqRating && p.rating < item.reqRating) || + (item.reqAchievement && !(p.unlocked ?? []).includes(item.reqAchievement)) + ) return { ok: false, messageFa: "هنوز باز نشده است", messageEn: "Locked — requirement not met" }; // XP packs are consumable — grant XP instead of adding to an owned list. diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 84fedcc..46d5f6b 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -255,11 +255,10 @@ export interface CardBackDef { /** optional centered emblem glyph (luxury backs) */ motif?: string; default?: boolean; - unlockRating?: number; - unlockWins?: number; - /** purchase gate: locked until this level / rating is reached */ + /** purchase gate: locked (shown, not buyable) until met. Always priced. */ reqLevel?: number; reqRating?: number; + reqAchievement?: string; } export interface CardFrontDef { @@ -271,8 +270,9 @@ export interface CardFrontDef { border: string; // face border color price: number; default?: boolean; - unlockRating?: number; - unlockWins?: number; + reqLevel?: number; + reqRating?: number; + reqAchievement?: string; } /* --------------------- Reactions (Sheklak / شکلک) -------------------- */ @@ -282,10 +282,11 @@ export interface ReactionPackDef { nameFa: string; nameEn: string; reactions: string[]; // emoji/sticker chars - price: number; // >0 → purchasable in the shop + price: number; // coins (always purchasable; req* may gate it) default?: boolean; // owned from the start - unlockRating?: number; // earned by reaching this rating - unlockWins?: number; // earned by total wins + reqLevel?: number; + reqRating?: number; + reqAchievement?: string; } export interface StickerPackDef { @@ -293,11 +294,11 @@ export interface StickerPackDef { nameFa: string; nameEn: string; stickers: string[]; // Sticker artwork ids (see components/online/Sticker.tsx) - price: number; // >0 → purchasable in the shop + price: number; // coins (always purchasable; req* may gate it) default?: boolean; - unlockRating?: number; - unlockWins?: number; - unlockAchievement?: string; // earned when this achievement id is unlocked + reqLevel?: number; + reqRating?: number; + reqAchievement?: string; // gate: must have unlocked this achievement to buy } /* ------------------------------ Friends ------------------------------ */ @@ -471,9 +472,10 @@ export interface ShopItem { /** short fa/en description of what the item is/does */ descFa?: string; descEn?: string; - /** purchase gate: locked (shown, not buyable) until met. Server-enforced via the tier in the id. */ + /** purchase gate: locked (shown, not buyable) until met. Server-enforced. */ reqLevel?: number; reqRating?: number; + reqAchievement?: string; } /* ------------------------------ Coin packs --------------------------- */ @@ -611,13 +613,12 @@ export interface AppNotification { export interface AvatarDef { id: string; emoji: string; - price?: number; // >0 → purchasable in the shop + price?: number; // coins (always purchasable; req* may gate it) default?: boolean; // owned from the start - unlockRating?: number; // earned at this rating (better avatars = higher rank) - unlockWins?: number; - /** purchase gate: locked until this level / rating is reached */ + /** purchase gate: locked (shown, not buyable) until met. Always priced. */ reqLevel?: number; reqRating?: number; + reqAchievement?: string; } export const AVATARS: AvatarDef[] = [ @@ -640,14 +641,14 @@ export const AVATARS: AvatarDef[] = [ { id: "a-diamond", emoji: "💎", price: 3000 }, { id: "a-moneybag", emoji: "💰", price: 3500 }, { id: "a-trophy", emoji: "🏆", price: 4000 }, - // 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 }, - { id: "a-gem", emoji: "💠", unlockRating: 2100 }, + // Rank/achievement gated — always buyable with coins once the gate is met. + { id: "a-robot", emoji: "🤖", price: 1200, reqAchievement: "wins_50" }, + { id: "a-wizard", emoji: "🧙", price: 2500, reqRating: 1300 }, + { id: "a-ninja", emoji: "🥷", price: 1800, reqAchievement: "wins_100" }, + { id: "a-king", emoji: "🤴", price: 3000, reqRating: 1500 }, + { id: "a-genie", emoji: "🧞", price: 3800, reqRating: 1700 }, + { id: "a-crown", emoji: "👑", price: 5000, reqRating: 1900, reqAchievement: "hakem_7" }, + { id: "a-gem", emoji: "💠", price: 6500, reqRating: 2100, reqAchievement: "shutout_10" }, ]; /* ----------------------- Gated gift catalogue ------------------------ */