Shop: every item is coin-priced; level/rank/achievement only gate the purchase
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled

No more earned-only (rank/wins) cosmetics — every avatar, card back/front,
reaction & sticker pack now has a coin price. Rank/wins/achievement become
purchase requirements (coin · coin+rank · coin+rank+achievement), enforced
client (mock + ShopScreen lock label) and server (ProfileService.ItemGate,
keyed by kind:id). Ownership = default + purchased only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 21:27:25 +03:30
parent ccfc9b0536
commit 72efc03e2d
6 changed files with 137 additions and 78 deletions
@@ -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<string, (int Level, int Rating, string? Ach)> 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")
{
+5
View File
@@ -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;
};
+2
View File
@@ -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",
+39 -51
View File
@@ -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<string>();
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<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);
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<string>();
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 "SevenZip" (70 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 "SevenZip" (70 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<string>();
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];
}
+17 -2
View File
@@ -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.
+26 -25
View File
@@ -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 ------------------------ */