diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index a2c7d0a..619f7da 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -125,10 +125,28 @@ public class ProfileService ["xp3"] = (8000, 1500), }; + // Gated gifts encode their tier in the id (`-t-`); 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) { 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. if (kind == "xp") { diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index c31995a..2eb13d7 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -1,7 +1,7 @@ "use client"; 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 { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; 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 before = profile; const res = await getService().buyItem(item.id); @@ -179,7 +186,7 @@ export function ShopScreen() {
{list.map((item) => ( - setDetail(item)} /> + setDetail(item)} /> ))}
@@ -192,6 +199,7 @@ export function ShopScreen() { item={detail} owned={owns(detail)} coins={profile.coins} + reqLabel={lockLabel(detail)} onBuy={() => buy(detail)} 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 count = item.contents?.length; const luxury = item.price >= 2000; // premium "luxury" tier + const locked = !owned && !!reqLabel; return ( )} -
+
@@ -254,11 +263,16 @@ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onO {owned ? ( + ) : locked ? ( + <> + + {reqLabel} + ) : ( <> @@ -274,18 +288,21 @@ function DetailSheet({ item, owned, coins, + reqLabel, onBuy, onClose, }: { item: ShopItem; owned: boolean; coins: number; + reqLabel: string | null; onBuy: () => void; onClose: () => void; }) { const { t, locale } = useI18n(); const name = locale === "fa" ? item.nameFa : item.nameEn; const desc = locale === "fa" ? item.descFa : item.descEn; + const locked = !owned && !!reqLabel; const canAfford = coins >= item.price; return ( @@ -346,11 +363,13 @@ function DetailSheet({ {/* buy */}