100 gated gifts (level/rating-locked) + requirement system
CI/CD / CI - API (dotnet build + engine sim) (push) Has been cancelled
CI/CD / CI - Web (tsc + next build) (push) Has been cancelled
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled

Adds ~100 new purchasable gifts that are LOCKED until a level/rating gate is met,
then buyable with coins — value scales with the gate:
- 45 gift avatars (types.ts), 35 gift titles + 20 gift card backs (gamification.ts),
  all reusing existing renderers. Tier (1-5) encoded in the id (-t<n>-).
- Gate model: GIFT_TIERS (shared) → reqLevel/reqRating on AvatarDef/TitleDef/
  CardBackDef + ShopItem. Tiers: t1 free, t2 Lv10, t3 Lv20, t4 Lv35, t5 Rating1700.
- Shop UI: locked cards dim + show the requirement (Lock + "Level 20"), buy
  disabled until met; mock buyItem enforces it offline.
- Server enforces generically — ProfileService parses the tier from the id and
  checks the player's level/rating (no 100-entry mirror). Mirrors GIFT_TIERS.
- i18n shop.reqLevel/reqRating (fa+en).

Verified: tsc + sim + next build + dotnet build all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 00:02:28 +03:30
parent e49df07c0f
commit 38ac8b06d1
6 changed files with 166 additions and 6 deletions
@@ -125,10 +125,28 @@ public class ProfileService
["xp3"] = (8000, 1500), ["xp3"] = (8000, 1500),
}; };
// Gated gifts encode their tier in the id (`-t<n>-`); the gate is derived from
// the tier so the server enforces it without a 100-entry catalog mirror.
// Mirrors GIFT_TIERS in src/lib/online/types.ts.
private static readonly (int Level, int Rating)[] GiftGate =
{ (0, 0), (0, 0), (10, 0), (20, 0), (35, 0), (0, 1700) }; // index = tier (1..5)
private static (int Level, int Rating) GiftGateFor(string id)
{
var m = System.Text.RegularExpressions.Regex.Match(id, @"-t(\d)-");
if (m.Success && int.TryParse(m.Groups[1].Value, out var tier) && tier >= 1 && tier <= 5)
return GiftGate[tier];
return (0, 0);
}
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);
// Gated gift: locked until the player meets the tier's level/rating gate.
var gate = GiftGateFor(id);
if (p.Level < gate.Level || p.Rating < gate.Rating) 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")
{ {
+29 -6
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Check, Coins, Sparkles, X } from "lucide-react"; import { Check, Coins, Lock, Sparkles, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { Sticker } from "@/components/online/Sticker"; import { Sticker } from "@/components/online/Sticker";
@@ -94,6 +94,13 @@ export function ShopScreen() {
} }
}; };
// Requirement gate: returns a label while LOCKED, else null.
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}`;
return null;
};
const buy = async (item: ShopItem) => { const buy = async (item: ShopItem) => {
const before = profile; const before = profile;
const res = await getService().buyItem(item.id); const res = await getService().buyItem(item.id);
@@ -179,7 +186,7 @@ export function ShopScreen() {
<Section key={sec.kind} title={sec.title} hint={sec.hint}> <Section key={sec.kind} title={sec.title} hint={sec.hint}>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{list.map((item) => ( {list.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} onOpen={() => setDetail(item)} /> <ItemCard key={item.id} item={item} owned={owns(item)} reqLabel={lockLabel(item)} onOpen={() => setDetail(item)} />
))} ))}
</div> </div>
</Section> </Section>
@@ -192,6 +199,7 @@ export function ShopScreen() {
item={detail} item={detail}
owned={owns(detail)} owned={owns(detail)}
coins={profile.coins} coins={profile.coins}
reqLabel={lockLabel(detail)}
onBuy={() => buy(detail)} onBuy={() => buy(detail)}
onClose={() => setDetail(null)} onClose={() => setDetail(null)}
/> />
@@ -212,10 +220,11 @@ function Section({ title, hint, children }: { title: string; hint?: string; chil
); );
} }
function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) { function ItemCard({ item, owned, reqLabel, onOpen }: { item: ShopItem; owned: boolean; reqLabel: string | null; onOpen: () => void }) {
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const count = item.contents?.length; const count = item.contents?.length;
const luxury = item.price >= 2000; // premium "luxury" tier const luxury = item.price >= 2000; // premium "luxury" tier
const locked = !owned && !!reqLabel;
return ( return (
<motion.button <motion.button
whileTap={{ scale: 0.96 }} whileTap={{ scale: 0.96 }}
@@ -235,7 +244,7 @@ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onO
<Check className="size-3.5" strokeWidth={3} /> <Check className="size-3.5" strokeWidth={3} />
</span> </span>
)} )}
<div className="h-12 flex items-center justify-center"> <div className={cn("h-12 flex items-center justify-center", locked && "opacity-40 grayscale")}>
<Preview item={item} size={44} /> <Preview item={item} size={44} />
</div> </div>
<span className="text-[11px] font-semibold text-cream/90 truncate max-w-full"> <span className="text-[11px] font-semibold text-cream/90 truncate max-w-full">
@@ -254,11 +263,16 @@ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onO
<span <span
className={cn( className={cn(
"w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1", "w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1",
owned ? "bg-navy-900/60 text-teal-300" : "btn-gold" owned ? "bg-navy-900/60 text-teal-300" : locked ? "bg-navy-900/70 text-cream/55" : "btn-gold"
)} )}
> >
{owned ? ( {owned ? (
<Check className="size-3.5" /> <Check className="size-3.5" />
) : locked ? (
<>
<Lock className="size-3" />
<span className="text-[10px] truncate">{reqLabel}</span>
</>
) : ( ) : (
<> <>
<Coins className="size-3.5" /> <Coins className="size-3.5" />
@@ -274,18 +288,21 @@ function DetailSheet({
item, item,
owned, owned,
coins, coins,
reqLabel,
onBuy, onBuy,
onClose, onClose,
}: { }: {
item: ShopItem; item: ShopItem;
owned: boolean; owned: boolean;
coins: number; coins: number;
reqLabel: string | null;
onBuy: () => void; onBuy: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const name = locale === "fa" ? item.nameFa : item.nameEn; const name = locale === "fa" ? item.nameFa : item.nameEn;
const desc = locale === "fa" ? item.descFa : item.descEn; const desc = locale === "fa" ? item.descFa : item.descEn;
const locked = !owned && !!reqLabel;
const canAfford = coins >= item.price; const canAfford = coins >= item.price;
return ( return (
@@ -346,11 +363,13 @@ function DetailSheet({
{/* buy */} {/* buy */}
<button <button
onClick={onBuy} onClick={onBuy}
disabled={owned || !canAfford} disabled={owned || locked || !canAfford}
className={cn( className={cn(
"press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2", "press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2",
owned owned
? "bg-navy-900/60 text-teal-300" ? "bg-navy-900/60 text-teal-300"
: locked
? "bg-navy-900/60 text-cream/60"
: canAfford : canAfford
? "btn-gold" ? "btn-gold"
: "bg-navy-900/60 text-rose-300" : "bg-navy-900/60 text-rose-300"
@@ -360,6 +379,10 @@ function DetailSheet({
<> <>
<Check className="size-4" /> {t("shop.owned")} <Check className="size-4" /> {t("shop.owned")}
</> </>
) : locked ? (
<>
<Lock className="size-4" /> {reqLabel}
</>
) : canAfford ? ( ) : canAfford ? (
<> <>
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()} <Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
+4
View File
@@ -310,6 +310,8 @@ const fa: Dict = {
"shop.xp": "امتیاز تجربه (XP)", "shop.xp": "امتیاز تجربه (XP)",
"shop.xpHint": "افزایش سریع سطح — XP گران است", "shop.xpHint": "افزایش سریع سطح — XP گران است",
"shop.includes": "شامل", "shop.includes": "شامل",
"shop.reqLevel": "سطح",
"shop.reqRating": "امتیاز",
"reward.newTitle": "عنوان جدید", "reward.newTitle": "عنوان جدید",
"reactions.title": "شکلک", "reactions.title": "شکلک",
@@ -640,6 +642,8 @@ const en: Dict = {
"shop.xp": "XP packs", "shop.xp": "XP packs",
"shop.xpHint": "Level up faster — XP is expensive", "shop.xpHint": "Level up faster — XP is expensive",
"shop.includes": "Includes", "shop.includes": "Includes",
"shop.reqLevel": "Level",
"shop.reqRating": "Rating",
"reward.newTitle": "New title", "reward.newTitle": "New title",
"reactions.title": "Emoji", "reactions.title": "Emoji",
+57
View File
@@ -3,6 +3,7 @@
import { import {
AVATARS, AVATARS,
GIFT_TIERS,
AchievementCategoryDef, AchievementCategoryDef,
AchievementCategoryId, AchievementCategoryId,
AchievementDef, AchievementDef,
@@ -499,6 +500,62 @@ export const CARD_FRONTS: CardFrontDef[] = [
{ 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 },
]; ];
/* ------------------- Gated gift catalogue (titles + backs) -------------------
* Purchasable gifts that are LOCKED until a level/rating gate is met (the tier is
* encoded in the id `-t<n>-` so the server enforces it generically). Gift avatars
* live in types.ts (same scheme). ~100 new gifts total across the three kinds. */
function giftReq(tier: number) {
const t = GIFT_TIERS[tier] ?? GIFT_TIERS[1];
return { price: t.price, reqLevel: t.level || undefined, reqRating: t.rating || undefined };
}
// 35 gift titles (fa, en), spread across the 5 tiers.
const GIFT_TITLE_WORDS: [string, string][] = [
["سردار", "Commander"], ["یل", "Brave"], ["پهلوان", "Hero"], ["شیردل", "Lionheart"], ["تیزهوش", "Sharp"], ["زبده", "Ace"], ["کارکشته", "Veteran"],
["نخبه", "Elite"], ["بی‌رقیب", "Unrivaled"], ["شکست‌ناپذیر", "Invincible"], ["آتشین", "Fiery"], ["طوفان", "Storm"], ["صاعقه", "Thunder"], ["کولاک", "Blizzard"],
["تاجدار", "Crowned"], ["فرمانروا", "Ruler"], ["شاهباز", "Royal Falcon"], ["گرگ تنها", "Lone Wolf"], ["عقاب", "Eagle"], ["ققنوس", "Phoenix"], ["محاسب", "Tactician"],
["نقشه‌کش", "Strategist"], ["بازی‌ساز", "Playmaker"], ["پادشاه میز", "Table King"], ["حکم‌ران", "Hokm Lord"], ["برگ‌برنده", "Trump Card"], ["دست‌مریزاد", "Masterstroke"], ["افسانه‌ساز", "Legend-Maker"],
["جواهر", "Gem"], ["الماس", "Diamond"], ["زرین", "Golden"], ["شاهنشاه", "Emperor"], ["اسطوره زنده", "Living Legend"], ["استاد اعظم", "Grandmaster"], ["تسخیرناپذیر", "Untamed"],
];
TITLES.push(
...GIFT_TITLE_WORDS.map(([fa, en], i) => {
const tier = Math.min(5, Math.floor(i / 7) + 1);
const r = giftReq(tier);
return { id: `t-g-t${tier}-${i + 1}`, nameFa: fa, nameEn: en, hintFa: "گیفت ویژه", hintEn: "Special gift", price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating };
})
);
// 20 gift card backs (palette × pattern), spread across the 5 tiers.
const GIFT_BACK_PALETTE: { fa: string; en: string; c1: string; c2: string; accent: string; pattern: CardBackDef["pattern"]; motif?: string }[] = [
{ fa: "نیلی", en: "Indigo", c1: "#2b2f7a", c2: "#10112e", accent: "#8aa0ff", pattern: "stripes" },
{ fa: "زمردین", en: "Verdant", c1: "#0d5a44", c2: "#06231a", accent: "#4fe0a8", pattern: "dots" },
{ fa: "غروب", en: "Sunset", c1: "#7a3b12", c2: "#2a1407", accent: "#ffb066", pattern: "rays" },
{ fa: "بنفشه", en: "Violet", c1: "#532a7a", c2: "#1d0e2e", accent: "#c79cff", pattern: "argyle", motif: "✦" },
{ fa: "فیروزه", en: "Turquoise", c1: "#0e5d6e", c2: "#06232a", accent: "#5fe0e0", pattern: "scales" },
{ fa: "گلگون", en: "Rose", c1: "#7a2340", c2: "#2a0c16", accent: "#ff8ab0", pattern: "grid" },
{ fa: "شنی", en: "Sand", c1: "#7a5a1a", c2: "#2a1e08", accent: "#e6c66a", pattern: "crosshatch" },
{ fa: "یخی", en: "Frost", c1: "#3a5a7a", c2: "#13202e", accent: "#9fd0ff", pattern: "dots" },
{ fa: "ارغوان", en: "Magenta", c1: "#7a1f5a", c2: "#2a0a1e", accent: "#ff8ae0", pattern: "rays" },
{ fa: "جنگلی", en: "Forest", c1: "#2e5a1a", c2: "#0e2008", accent: "#9ee06a", pattern: "stripes" },
{ fa: "نقره‌ای", en: "Silver", c1: "#4a4f5a", c2: "#16181d", accent: "#cfd6e6", pattern: "argyle", motif: "♢" },
{ fa: "مسی", en: "Copper", c1: "#7a3f1a", c2: "#2a1508", accent: "#e6975a", pattern: "scales" },
{ fa: "یاقوتی", en: "Garnet", c1: "#6a1326", c2: "#240710", accent: "#ff7a90", pattern: "filigree", motif: "♦" },
{ fa: "کبریتی", en: "Sulfur", c1: "#6a6010", c2: "#242008", accent: "#ffe46a", pattern: "grid" },
{ fa: "اطلسی", en: "Petunia", c1: "#3a2a7a", c2: "#12102e", accent: "#9a8aff", pattern: "royal", motif: "♛" },
{ fa: "زمستانی", en: "Winter", c1: "#1f4a6a", c2: "#0a1a26", accent: "#7fc6ff", pattern: "filigree", motif: "❄" },
{ fa: "آتشفشان", en: "Volcano", c1: "#6a1f10", c2: "#240a06", accent: "#ff7a4a", pattern: "rays", motif: "✦" },
{ fa: "کهکشان", en: "Galaxy", c1: "#241a4a", c2: "#0a0820", accent: "#a98aff", pattern: "gem", motif: "✧" },
{ fa: "زرافشان", en: "Goldspark", c1: "#5a4410", c2: "#241a06", accent: "#ffd76a", pattern: "royal", motif: "♔" },
{ fa: "الماسی", en: "Brilliant", c1: "#103a4a", c2: "#06181f", accent: "#7fe6ff", pattern: "gem", motif: "♦" },
];
CARD_BACKS.push(
...GIFT_BACK_PALETTE.map((b, i) => {
const tier = Math.min(5, Math.floor(i / 4) + 1);
const r = giftReq(tier);
return { id: `cb-g-t${tier}-${i + 1}`, nameFa: b.fa, nameEn: b.en, c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif, price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating };
})
);
export function cardBackById(id: string): CardBackDef { export function cardBackById(id: string): CardBackDef {
return CARD_BACKS.find((c) => c.id === id) ?? CARD_BACKS[0]; return CARD_BACKS.find((c) => c.id === id) ?? CARD_BACKS[0];
} }
+10
View File
@@ -1031,6 +1031,8 @@ export class MockOnlineService implements OnlineService {
preview: a.emoji, preview: a.emoji,
descFa: "آواتار نمایه شما در بازی و جدول", descFa: "آواتار نمایه شما در بازی و جدول",
descEn: "Your profile avatar in games & leaderboard", descEn: "Your profile avatar in games & leaderboard",
reqLevel: a.reqLevel,
reqRating: a.reqRating,
})); }));
const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({ const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({
id: c.id, id: c.id,
@@ -1041,6 +1043,8 @@ export class MockOnlineService implements OnlineService {
preview: c.accent, preview: c.accent,
descFa: "طرح پشت کارت‌ها روی میز", descFa: "طرح پشت کارت‌ها روی میز",
descEn: "The pattern on the back of your cards", descEn: "The pattern on the back of your cards",
reqLevel: c.reqLevel,
reqRating: c.reqRating,
})); }));
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,
@@ -1083,6 +1087,8 @@ export class MockOnlineService implements OnlineService {
preview: "🏷️", preview: "🏷️",
descFa: "عنوان نمایه که زیر نام شما در بازی و لیست‌ها نشان داده می‌شود", descFa: "عنوان نمایه که زیر نام شما در بازی و لیست‌ها نشان داده می‌شود",
descEn: "A profile title shown under your name in games & lists", descEn: "A profile title shown under your name in games & lists",
reqLevel: tt.reqLevel,
reqRating: tt.reqRating,
})); }));
const xpItems: ShopItem[] = XP_PACKS.map((x) => ({ const xpItems: ShopItem[] = XP_PACKS.map((x) => ({
id: x.id, id: x.id,
@@ -1104,6 +1110,10 @@ 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.
if ((item.reqLevel && p.level < item.reqLevel) || (item.reqRating && p.rating < item.reqRating))
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.
if (item.kind === "xp") { if (item.kind === "xp") {
if (p.coins < item.price) if (p.coins < item.price)
+48
View File
@@ -224,6 +224,9 @@ export interface TitleDef {
hintEn: string; hintEn: string;
/** >0 = purchasable in the shop (otherwise unlocked via stats/rank) */ /** >0 = purchasable in the shop (otherwise unlocked via stats/rank) */
price?: number; price?: number;
/** purchase gate: must reach this level / rating before buying (locked until then) */
reqLevel?: number;
reqRating?: number;
} }
/** Distinct visual pattern families for card backs (see lib/cardBack.ts). */ /** Distinct visual pattern families for card backs (see lib/cardBack.ts). */
@@ -254,6 +257,9 @@ export interface CardBackDef {
default?: boolean; default?: boolean;
unlockRating?: number; unlockRating?: number;
unlockWins?: number; unlockWins?: number;
/** purchase gate: locked until this level / rating is reached */
reqLevel?: number;
reqRating?: number;
} }
export interface CardFrontDef { export interface CardFrontDef {
@@ -465,6 +471,9 @@ 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. */
reqLevel?: number;
reqRating?: number;
} }
/* ------------------------------ Coin packs --------------------------- */ /* ------------------------------ Coin packs --------------------------- */
@@ -606,6 +615,9 @@ export interface AvatarDef {
default?: boolean; // owned from the start default?: boolean; // owned from the start
unlockRating?: number; // earned at this rating (better avatars = higher rank) unlockRating?: number; // earned at this rating (better avatars = higher rank)
unlockWins?: number; unlockWins?: number;
/** purchase gate: locked until this level / rating is reached */
reqLevel?: number;
reqRating?: number;
} }
export const AVATARS: AvatarDef[] = [ export const AVATARS: AvatarDef[] = [
@@ -638,6 +650,42 @@ export const AVATARS: AvatarDef[] = [
{ id: "a-gem", emoji: "💠", unlockRating: 2100 }, { id: "a-gem", emoji: "💠", unlockRating: 2100 },
]; ];
/* ----------------------- Gated gift catalogue ------------------------ */
// Purchasable gifts that are LOCKED until a level/rating gate is met, then
// buyable with coins. The tier is encoded in the id (`-t<n>-`) so the SERVER
// can enforce the gate generically (see ProfileService.GiftTier). Higher tier =
// more valuable = steeper gate + price. Shared by client UI + server.
export const GIFT_TIERS: Record<number, { level: number; rating: number; price: number }> = {
1: { level: 0, rating: 0, price: 800 },
2: { level: 10, rating: 0, price: 1500 },
3: { level: 20, rating: 0, price: 3000 },
4: { level: 35, rating: 0, price: 6000 },
5: { level: 0, rating: 1700, price: 12000 },
};
export function giftTierFromId(id: string): number {
const m = /-t(\d)-/.exec(id);
return m ? Number(m[1]) : 1;
}
function giftReq(tier: number) {
const t = GIFT_TIERS[tier] ?? GIFT_TIERS[1];
return { price: t.price, reqLevel: t.level || undefined, reqRating: t.rating || undefined };
}
// 45 gift avatars (emoji), spread across the 5 tiers.
const GIFT_AVATAR_EMOJIS = [
"🐺", "🐯", "🐲", "🦅", "🦈", "🐉", "🦄", "🐙", "🦂", // t1-ish
"🐧", "🐨", "🐸", "🦔", "🐢", "🦩", "🦚", "🐬", "🦓", // t2
"🦁", "🐅", "🐆", "🦛", "🦏", "🐘", "🦒", "🦌", "🐊", // t3
"👻", "🤡", "👽", "🤠", "🦸", "🦹", "🧛", "🧟", "🧜", // t4
"🔱", "⚜️", "🛡️", "⚔️", "🏵️", "🎴", "🀄", "🪅", "🧿", // t5
];
const GIFT_AVATARS: AvatarDef[] = GIFT_AVATAR_EMOJIS.map((emoji, i) => {
const tier = Math.min(5, Math.floor(i / 9) + 1);
const r = giftReq(tier);
return { id: `a-g-t${tier}-${i + 1}`, emoji, price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating };
});
AVATARS.push(...GIFT_AVATARS);
export function avatarEmoji(id: string): string { export function avatarEmoji(id: string): string {
return AVATARS.find((a) => a.id === id)?.emoji ?? "🦊"; return AVATARS.find((a) => a.id === id)?.emoji ?? "🦊";
} }