Shop: every item is coin-priced; level/rank/achievement only gate the purchase
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:
@@ -139,6 +139,47 @@ public class ProfileService
|
|||||||
return (0, 0);
|
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)
|
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreate(uid, null);
|
var p = await GetOrCreate(uid, null);
|
||||||
@@ -147,6 +188,13 @@ public class ProfileService
|
|||||||
var gate = GiftGateFor(id);
|
var gate = GiftGateFor(id);
|
||||||
if (p.Level < gate.Level || p.Rating < gate.Rating) return (false, p, "locked");
|
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.
|
// XP packs are consumable (grant XP, may level up) — not added to an owned list.
|
||||||
if (kind == "xp")
|
if (kind == "xp")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ export function ShopScreen() {
|
|||||||
const lockLabel = (item: ShopItem): string | null => {
|
const lockLabel = (item: ShopItem): string | null => {
|
||||||
if (item.reqLevel && profile.level < item.reqLevel) return `${t("shop.reqLevel")} ${item.reqLevel}`;
|
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.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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ const fa: Dict = {
|
|||||||
"shop.includes": "شامل",
|
"shop.includes": "شامل",
|
||||||
"shop.reqLevel": "سطح",
|
"shop.reqLevel": "سطح",
|
||||||
"shop.reqRating": "امتیاز",
|
"shop.reqRating": "امتیاز",
|
||||||
|
"shop.reqAchv": "دستاورد:",
|
||||||
"reward.newTitle": "عنوان جدید",
|
"reward.newTitle": "عنوان جدید",
|
||||||
|
|
||||||
"reactions.title": "شکلک",
|
"reactions.title": "شکلک",
|
||||||
@@ -644,6 +645,7 @@ const en: Dict = {
|
|||||||
"shop.includes": "Includes",
|
"shop.includes": "Includes",
|
||||||
"shop.reqLevel": "Level",
|
"shop.reqLevel": "Level",
|
||||||
"shop.reqRating": "Rating",
|
"shop.reqRating": "Rating",
|
||||||
|
"shop.reqAchv": "Achievement:",
|
||||||
"reward.newTitle": "New title",
|
"reward.newTitle": "New title",
|
||||||
|
|
||||||
"reactions.title": "Emoji",
|
"reactions.title": "Emoji",
|
||||||
|
|||||||
@@ -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 {
|
export function stickerPackForAchievement(achId: string): StickerPackDef | undefined {
|
||||||
return STICKER_PACKS.find((p) => p.unlockAchievement === achId);
|
return STICKER_PACKS.find((p) => p.reqAchievement === achId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ Titles ------------------------------- */
|
/* ------------------------------ 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: "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: "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" },
|
{ 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
|
// Rank/achievement gated — always buyable with coins once the gate is met.
|
||||||
{ id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 0, unlockWins: 25, pattern: "rays" },
|
{ 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: 0, unlockRating: 1300, pattern: "argyle", motif: "♦" },
|
{ 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: 0, unlockWins: 50, pattern: "royal", 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: 0, unlockRating: 1500, pattern: "rays" },
|
{ 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: 0, unlockRating: 1700, pattern: "crosshatch", motif: "✦" },
|
{ 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: 0, unlockRating: 1900, pattern: "royal", 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
|
// ✨ 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: "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: "♠" },
|
{ 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: "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: "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 },
|
{ id: "onyx-face", nameFa: "شبرنگ", nameEn: "Onyx", bg1: "#2a2a31", bg2: "#16161c", border: "#5a5a6a", price: 1200 },
|
||||||
// earned by rank / wins
|
// Rank/achievement gated — always buyable with coins once the gate is met.
|
||||||
{ id: "parchment", nameFa: "پوستنوشت", nameEn: "Parchment", bg1: "#fbf2d8", bg2: "#efd9a3", border: "#caa84a", price: 0, unlockRating: 1300 },
|
{ 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: 0, unlockWins: 50 },
|
{ 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: 0, unlockRating: 1500 },
|
{ 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: 0, unlockRating: 1700 },
|
{ 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: 0, unlockWins: 100 },
|
{ id: "imperial-face", nameFa: "شاهانه", nameEn: "Imperial", bg1: "#fff4cf", bg2: "#ecc873", border: "#b8862a", price: 2800, reqAchievement: "wins_100" },
|
||||||
// ✨ Luxury card fronts — premium purchasable
|
// ✨ Luxury card fronts — premium purchasable
|
||||||
{ id: "diamond-face", nameFa: "الماس", nameEn: "Diamond", bg1: "#f4fdff", bg2: "#d7f0fb", border: "#7fc6e6", price: 2500 },
|
{ 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 },
|
{ 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];
|
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(
|
function ownedCosmeticIds(
|
||||||
defs: { id: string; price: number; default?: boolean; unlockRating?: number; unlockWins?: number }[],
|
defs: { id: string; default?: boolean }[],
|
||||||
profile: UserProfile,
|
_profile: UserProfile,
|
||||||
purchased: string[]
|
purchased: string[]
|
||||||
): string[] {
|
): string[] {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const d of defs) {
|
for (const d of defs) {
|
||||||
const earned =
|
if (d.default || purchased.includes(d.id)) ids.add(d.id);
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
@@ -582,15 +581,12 @@ export function ownedCardBackIds(profile: UserProfile): string[] {
|
|||||||
return ownedCosmeticIds(CARD_BACKS, profile, profile.ownedCardBacks ?? []);
|
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[] {
|
export function ownedAvatarIds(profile: UserProfile): string[] {
|
||||||
const purchased = profile.ownedAvatars ?? [];
|
const purchased = profile.ownedAvatars ?? [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const a of AVATARS) {
|
for (const a of AVATARS) {
|
||||||
const earned =
|
if (a.default || purchased.includes(a.id)) ids.add(a.id);
|
||||||
(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];
|
return [...ids];
|
||||||
}
|
}
|
||||||
@@ -604,23 +600,20 @@ export const REACTION_PACKS: ReactionPackDef[] = [
|
|||||||
{ id: "starter", nameFa: "پایه", nameEn: "Starter", reactions: ["👍", "👏", "😂", "😮"], price: 0, default: true },
|
{ id: "starter", nameFa: "پایه", nameEn: "Starter", reactions: ["👍", "👏", "😂", "😮"], price: 0, default: true },
|
||||||
{ id: "emotions", nameFa: "احساسات", nameEn: "Emotions", reactions: ["😎", "😭", "🤯", "🥳", "😍"], price: 600 },
|
{ id: "emotions", nameFa: "احساسات", nameEn: "Emotions", reactions: ["😎", "😭", "🤯", "🥳", "😍"], price: 600 },
|
||||||
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", reactions: ["😏", "🤡", "🙄", "😴", "🥱"], price: 900 },
|
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", reactions: ["😏", "🤡", "🙄", "😴", "🥱"], price: 900 },
|
||||||
{ id: "champion", nameFa: "قهرمان", nameEn: "Champion", reactions: ["👑", "🏆", "💪", "🔥"], price: 0, unlockRating: 1300 },
|
{ id: "champion", nameFa: "قهرمان", nameEn: "Champion", reactions: ["👑", "🏆", "💪", "🔥"], price: 1200, reqRating: 1300 },
|
||||||
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", reactions: ["💎", "⚡", "🐐", "🎯"], price: 0, unlockWins: 100 },
|
{ id: "legend", nameFa: "اسطوره", nameEn: "Legend", reactions: ["💎", "⚡", "🐐", "🎯"], price: 1800, reqAchievement: "wins_100" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function reactionPackById(id: string): ReactionPackDef | undefined {
|
export function reactionPackById(id: string): ReactionPackDef | undefined {
|
||||||
return REACTION_PACKS.find((p) => p.id === id);
|
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[] {
|
export function ownedReactionPackIds(profile: UserProfile): string[] {
|
||||||
const purchased = profile.ownedReactionPacks ?? [];
|
const purchased = profile.ownedReactionPacks ?? [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const p of REACTION_PACKS) {
|
for (const p of REACTION_PACKS) {
|
||||||
const earned =
|
if (p.default || purchased.includes(p.id)) ids.add(p.id);
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
@@ -635,17 +628,17 @@ export function ownedReactions(profile: UserProfile): string[] {
|
|||||||
|
|
||||||
export const STICKER_PACKS: StickerPackDef[] = [
|
export const STICKER_PACKS: StickerPackDef[] = [
|
||||||
{ id: "faces", nameFa: "شکلکها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true },
|
{ id: "faces", nameFa: "شکلکها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true },
|
||||||
// Earned by the "Seven–Zip" (7–0 sweep) achievement.
|
// 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: 0, unlockAchievement: "shutout_1" },
|
{ id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 1200, reqAchievement: "shutout_1" },
|
||||||
// Earned by the "100 Wins" achievement.
|
// Achievement-gated — "100 Wins".
|
||||||
{ id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700, unlockAchievement: "wins_100" },
|
{ id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700, reqAchievement: "wins_100" },
|
||||||
// Earned by the "25 Kots" achievement.
|
// Achievement-gated — "25 Kots".
|
||||||
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900, unlockAchievement: "kot_25" },
|
{ id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900, reqAchievement: "kot_25" },
|
||||||
// Persian-text stamps (کوت! / دمت گرم / باریکلا / آخه؟) — purchasable.
|
// Persian-text stamps (کوت! / دمت گرم / باریکلا / آخه؟) — purchasable.
|
||||||
{ id: "persian-text", nameFa: "متن فارسی", nameEn: "Persian Text", stickers: ["kot-text", "damet-garm", "barikalla", "akhe"], price: 1100 },
|
{ id: "persian-text", nameFa: "متن فارسی", nameEn: "Persian Text", stickers: ["kot-text", "damet-garm", "barikalla", "akhe"], price: 1100 },
|
||||||
// Custom packs earned only via achievements / rank.
|
// Achievement-gated premium packs (coin + achievement).
|
||||||
{ id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 0, unlockAchievement: "hakem_7" },
|
{ id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 1500, reqAchievement: "hakem_7" },
|
||||||
{ id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 0, unlockAchievement: "streak_10" },
|
{ id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 1500, reqAchievement: "streak_10" },
|
||||||
|
|
||||||
/* ---- New themed packs: کلکل (banter), Persian trends, Hokm/game ---- */
|
/* ---- New themed packs: کلکل (banter), Persian trends, Hokm/game ---- */
|
||||||
// کلکل / تیکه — trash-talk you fling at the table
|
// کلکل / تیکه — 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 },
|
{ id: "tashvigh", nameFa: "تشویق", nameEn: "Cheers", stickers: ["damet-garm-2", "nush-jan", "be-be", "ghorbunet"], price: 700 },
|
||||||
// Hokm / card-game themed
|
// Hokm / card-game themed
|
||||||
{ id: "khanevadeh", nameFa: "خانواده خال", nameEn: "Court Cards", stickers: ["tak-khal", "as-del", "shah-khesht", "bibi-gesht"], price: 1200 },
|
{ 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
|
// Extra emotions
|
||||||
{ id: "ehsasat", nameFa: "احساسات", nameEn: "Moods", stickers: ["laugh", "shocked", "cry", "smug"], price: 600 },
|
{ id: "ehsasat", nameFa: "احساسات", nameEn: "Moods", stickers: ["laugh", "shocked", "cry", "smug"], price: 600 },
|
||||||
// Mega banter bundle (earned, not sold) — the spicy stuff for rivals
|
// Spicy rival banter — coin + achievement gated.
|
||||||
{ id: "raghib", nameFa: "رقیب", nameEn: "Rivalry", stickers: ["khdahafez", "weak", "clown", "sleep"], price: 0, unlockAchievement: "kot_10" },
|
{ id: "raghib", nameFa: "رقیب", nameEn: "Rivalry", stickers: ["khdahafez", "weak", "clown", "sleep"], price: 1300, reqAchievement: "kot_10" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function stickerPackById(id: string): StickerPackDef | undefined {
|
export function stickerPackById(id: string): StickerPackDef | undefined {
|
||||||
@@ -670,14 +663,9 @@ export function stickerPackById(id: string): StickerPackDef | undefined {
|
|||||||
|
|
||||||
export function ownedStickerPackIds(profile: UserProfile): string[] {
|
export function ownedStickerPackIds(profile: UserProfile): string[] {
|
||||||
const purchased = profile.ownedStickerPacks ?? [];
|
const purchased = profile.ownedStickerPacks ?? [];
|
||||||
const unlocked = profile.unlocked ?? [];
|
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const p of STICKER_PACKS) {
|
for (const p of STICKER_PACKS) {
|
||||||
const earned =
|
if (p.default || purchased.includes(p.id)) ids.add(p.id);
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1036,6 +1036,7 @@ export class MockOnlineService implements OnlineService {
|
|||||||
descEn: "A legendary profile avatar shown in games & the leaderboard",
|
descEn: "A legendary profile avatar shown in games & the leaderboard",
|
||||||
reqLevel: a.reqLevel,
|
reqLevel: a.reqLevel,
|
||||||
reqRating: a.reqRating,
|
reqRating: a.reqRating,
|
||||||
|
reqAchievement: a.reqAchievement,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({
|
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",
|
descEn: "The pattern on the back of your cards",
|
||||||
reqLevel: c.reqLevel,
|
reqLevel: c.reqLevel,
|
||||||
reqRating: c.reqRating,
|
reqRating: c.reqRating,
|
||||||
|
reqAchievement: c.reqAchievement,
|
||||||
}));
|
}));
|
||||||
const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({
|
const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -1059,6 +1061,9 @@ export class MockOnlineService implements OnlineService {
|
|||||||
preview: c.bg2,
|
preview: c.bg2,
|
||||||
descFa: "ظاهر روی کارتهای شما",
|
descFa: "ظاهر روی کارتهای شما",
|
||||||
descEn: "The face style of your cards",
|
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) => ({
|
const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -1070,6 +1075,9 @@ export class MockOnlineService implements OnlineService {
|
|||||||
contents: r.reactions,
|
contents: r.reactions,
|
||||||
descFa: `${faNum(r.reactions.length)} ایموجی برای استفاده در بازی`,
|
descFa: `${faNum(r.reactions.length)} ایموجی برای استفاده در بازی`,
|
||||||
descEn: `${r.reactions.length} in-game emotes`,
|
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) => ({
|
const stickerItems: ShopItem[] = STICKER_PACKS.filter((p) => p.price > 0).map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -1081,6 +1089,9 @@ export class MockOnlineService implements OnlineService {
|
|||||||
contents: p.stickers,
|
contents: p.stickers,
|
||||||
descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`,
|
descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`,
|
||||||
descEn: `${p.stickers.length} in-game stickers`,
|
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) => ({
|
const titleItems: ShopItem[] = TITLES.filter((tt) => (tt.price ?? 0) > 0).map((tt) => ({
|
||||||
id: tt.id,
|
id: tt.id,
|
||||||
@@ -1114,8 +1125,12 @@ export class MockOnlineService implements OnlineService {
|
|||||||
const item = items.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" };
|
if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" };
|
||||||
|
|
||||||
// Purchase gate: locked until the level/rating requirement is met.
|
// Purchase gate: locked until the level / rating / achievement requirement is met.
|
||||||
if ((item.reqLevel && p.level < item.reqLevel) || (item.reqRating && p.rating < item.reqRating))
|
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" };
|
return { ok: false, messageFa: "هنوز باز نشده است", messageEn: "Locked — requirement not met" };
|
||||||
|
|
||||||
// XP packs are consumable — grant XP instead of adding to an owned list.
|
// XP packs are consumable — grant XP instead of adding to an owned list.
|
||||||
|
|||||||
+26
-25
@@ -255,11 +255,10 @@ export interface CardBackDef {
|
|||||||
/** optional centered emblem glyph (luxury backs) */
|
/** optional centered emblem glyph (luxury backs) */
|
||||||
motif?: string;
|
motif?: string;
|
||||||
default?: boolean;
|
default?: boolean;
|
||||||
unlockRating?: number;
|
/** purchase gate: locked (shown, not buyable) until met. Always priced. */
|
||||||
unlockWins?: number;
|
|
||||||
/** purchase gate: locked until this level / rating is reached */
|
|
||||||
reqLevel?: number;
|
reqLevel?: number;
|
||||||
reqRating?: number;
|
reqRating?: number;
|
||||||
|
reqAchievement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardFrontDef {
|
export interface CardFrontDef {
|
||||||
@@ -271,8 +270,9 @@ export interface CardFrontDef {
|
|||||||
border: string; // face border color
|
border: string; // face border color
|
||||||
price: number;
|
price: number;
|
||||||
default?: boolean;
|
default?: boolean;
|
||||||
unlockRating?: number;
|
reqLevel?: number;
|
||||||
unlockWins?: number;
|
reqRating?: number;
|
||||||
|
reqAchievement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------- Reactions (Sheklak / شکلک) -------------------- */
|
/* --------------------- Reactions (Sheklak / شکلک) -------------------- */
|
||||||
@@ -282,10 +282,11 @@ export interface ReactionPackDef {
|
|||||||
nameFa: string;
|
nameFa: string;
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
reactions: string[]; // emoji/sticker chars
|
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
|
default?: boolean; // owned from the start
|
||||||
unlockRating?: number; // earned by reaching this rating
|
reqLevel?: number;
|
||||||
unlockWins?: number; // earned by total wins
|
reqRating?: number;
|
||||||
|
reqAchievement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StickerPackDef {
|
export interface StickerPackDef {
|
||||||
@@ -293,11 +294,11 @@ export interface StickerPackDef {
|
|||||||
nameFa: string;
|
nameFa: string;
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
stickers: string[]; // Sticker artwork ids (see components/online/Sticker.tsx)
|
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;
|
default?: boolean;
|
||||||
unlockRating?: number;
|
reqLevel?: number;
|
||||||
unlockWins?: number;
|
reqRating?: number;
|
||||||
unlockAchievement?: string; // earned when this achievement id is unlocked
|
reqAchievement?: string; // gate: must have unlocked this achievement to buy
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ Friends ------------------------------ */
|
/* ------------------------------ Friends ------------------------------ */
|
||||||
@@ -471,9 +472,10 @@ export interface ShopItem {
|
|||||||
/** short fa/en description of what the item is/does */
|
/** short fa/en description of what the item is/does */
|
||||||
descFa?: string;
|
descFa?: string;
|
||||||
descEn?: 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;
|
reqLevel?: number;
|
||||||
reqRating?: number;
|
reqRating?: number;
|
||||||
|
reqAchievement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ Coin packs --------------------------- */
|
/* ------------------------------ Coin packs --------------------------- */
|
||||||
@@ -611,13 +613,12 @@ export interface AppNotification {
|
|||||||
export interface AvatarDef {
|
export interface AvatarDef {
|
||||||
id: string;
|
id: string;
|
||||||
emoji: 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
|
default?: boolean; // owned from the start
|
||||||
unlockRating?: number; // earned at this rating (better avatars = higher rank)
|
/** purchase gate: locked (shown, not buyable) until met. Always priced. */
|
||||||
unlockWins?: number;
|
|
||||||
/** purchase gate: locked until this level / rating is reached */
|
|
||||||
reqLevel?: number;
|
reqLevel?: number;
|
||||||
reqRating?: number;
|
reqRating?: number;
|
||||||
|
reqAchievement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AVATARS: AvatarDef[] = [
|
export const AVATARS: AvatarDef[] = [
|
||||||
@@ -640,14 +641,14 @@ export const AVATARS: AvatarDef[] = [
|
|||||||
{ id: "a-diamond", emoji: "💎", price: 3000 },
|
{ id: "a-diamond", emoji: "💎", price: 3000 },
|
||||||
{ id: "a-moneybag", emoji: "💰", price: 3500 },
|
{ id: "a-moneybag", emoji: "💰", price: 3500 },
|
||||||
{ id: "a-trophy", emoji: "🏆", price: 4000 },
|
{ id: "a-trophy", emoji: "🏆", price: 4000 },
|
||||||
// earned by rank / wins — the rarer faces sit behind higher ranks
|
// Rank/achievement gated — always buyable with coins once the gate is met.
|
||||||
{ id: "a-robot", emoji: "🤖", unlockWins: 50 },
|
{ id: "a-robot", emoji: "🤖", price: 1200, reqAchievement: "wins_50" },
|
||||||
{ id: "a-wizard", emoji: "🧙", unlockRating: 1300 },
|
{ id: "a-wizard", emoji: "🧙", price: 2500, reqRating: 1300 },
|
||||||
{ id: "a-ninja", emoji: "🥷", unlockWins: 100 },
|
{ id: "a-ninja", emoji: "🥷", price: 1800, reqAchievement: "wins_100" },
|
||||||
{ id: "a-king", emoji: "🤴", unlockRating: 1500 },
|
{ id: "a-king", emoji: "🤴", price: 3000, reqRating: 1500 },
|
||||||
{ id: "a-genie", emoji: "🧞", unlockRating: 1700 },
|
{ id: "a-genie", emoji: "🧞", price: 3800, reqRating: 1700 },
|
||||||
{ id: "a-crown", emoji: "👑", unlockRating: 1900 },
|
{ id: "a-crown", emoji: "👑", price: 5000, reqRating: 1900, reqAchievement: "hakem_7" },
|
||||||
{ id: "a-gem", emoji: "💠", unlockRating: 2100 },
|
{ id: "a-gem", emoji: "💠", price: 6500, reqRating: 2100, reqAchievement: "shutout_10" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ----------------------- Gated gift catalogue ------------------------ */
|
/* ----------------------- Gated gift catalogue ------------------------ */
|
||||||
|
|||||||
Reference in New Issue
Block a user