From fd33f85e9c6c75023f41b255d7a041cf56abd4fa Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Fri, 5 Jun 2026 00:08:19 +0330 Subject: [PATCH] Store XP packs (expensive), winner 2x XP, premium perks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XP packs in the store (coin-priced, intentionally expensive): xp1 200/5k, xp2 600/12k, xp3 1500/25k. Consumable (grant XP, can level up) — server ShopBuy handles kind "xp" via an authoritative XpPacks map + Gamification.GrantXp; mock mirrors. New shop section + shop.xp/xpHint i18n. - Every game grants XP and the WINNER earns 2x: matchXp is now base*(won?2:1)*leagueFactor (was a flat +80 win bonus). Mirrored server-side. - Premium (pro) perks: 1.5x XP multiplier (applied in applyMatchResult / ApplyMatch by plan), plus animated shimmering gold chat bubbles for your own messages (premium-chat CSS; ChatScreen gates on plan). Verified: tsc + next + dotnet build clean; sim passes; live server — buying xp2 took L1→L3 and deducted 12k coins under the new curve. Images rebuilt :1500/:1505. Co-Authored-By: Claude Opus 4.8 --- .../src/Hokm.Server/Profiles/Gamification.cs | 20 ++++++++++++---- .../Hokm.Server/Profiles/ProfileService.cs | 21 ++++++++++++++++ src/app/globals.css | 14 +++++++++++ src/components/screens/ChatScreen.tsx | 4 +++- src/components/screens/ShopScreen.tsx | 18 ++++++++++++++ src/lib/i18n.tsx | 4 ++++ src/lib/online/gamification.ts | 22 +++++++++++------ src/lib/online/mock-service.ts | 24 ++++++++++++++++++- src/lib/online/types.ts | 3 ++- 9 files changed, 116 insertions(+), 14 deletions(-) diff --git a/server/src/Hokm.Server/Profiles/Gamification.cs b/server/src/Hokm.Server/Profiles/Gamification.cs index 92cbecb..28f1d09 100644 --- a/server/src/Hokm.Server/Profiles/Gamification.cs +++ b/server/src/Hokm.Server/Profiles/Gamification.cs @@ -41,10 +41,12 @@ public static class Gamification public const int MaxLevel = 100; public static int XpForLevel(int level) => 100 * level + 15 * level * level; private static double LeagueXpFactor(int stake) => stake >= 1000 ? 2.0 : stake >= 500 ? 1.5 : 1.0; + public const double PremiumXpMult = 1.5; public static int MatchXp(MatchSummaryDto s) { - int b = 40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0); - return (int)Math.Round(b * LeagueXpFactor(s.Stake)); + // Every game grants XP; the winner earns double. + int b = 40 + s.TricksWon * 5 + (s.KotFor ? 30 : 0); + return (int)Math.Round(b * (s.Won ? 2 : 1) * LeagueXpFactor(s.Stake)); } // metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach. @@ -138,6 +140,14 @@ public static class Gamification _ => false, }; + /// Grant raw XP to a profile (store XP packs); mutates level/xp, capped at 100. + public static void GrantXp(ProfileDto p, int xp) + { + var r = AddXp(p.Level, p.Xp, xp); + p.Level = r.level; + p.Xp = r.xp; + } + private static (int level, int xp, bool up) AddXp(int level, int xp, int gain) { bool up = false; @@ -154,7 +164,9 @@ public static class Gamification int rDelta = RatingDelta(s, p.Rating, oppRating); int ratingAfter = Math.Max(0, ratingBefore + rDelta); int cDelta = CoinDelta(s); - var lvl = AddXp(p.Level, p.Xp, MatchXp(s)); + // Premium (pro) players earn a multiple of XP. + int xpGain = (int)Math.Round(MatchXp(s) * (p.Plan == "pro" ? PremiumXpMult : 1.0)); + var lvl = AddXp(p.Level, p.Xp, xpGain); var st = p.Stats; int cur = s.Won ? st.CurrentWinStreak + 1 : 0; @@ -209,7 +221,7 @@ public static class Gamification CoinsBefore = coinsBefore, CoinsAfter = coinsAfter, CoinsDelta = coinsAfter - coinsBefore, - XpGained = MatchXp(s), + XpGained = xpGain, LevelBefore = levelBefore, LevelAfter = lvl.level, LeveledUp = lvl.level > levelBefore, diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index c722df0..cf3ab17 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -112,9 +112,30 @@ public class ProfileService /* ----------------------------- shop ------------------------------- */ + // Coin-priced XP packs (XP is intentionally expensive). Server-authoritative. + public static readonly Dictionary XpPacks = new() + { + ["xp1"] = (5000, 200), + ["xp2"] = (12000, 600), + ["xp3"] = (25000, 1500), + }; + public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price) { var p = await GetOrCreate(uid, null); + + // XP packs are consumable (grant XP, may level up) — not added to an owned list. + if (kind == "xp") + { + if (!XpPacks.TryGetValue(id, out var pk)) return (false, p, "bad_kind"); + if (p.Coins < pk.Price) return (false, p, "insufficient"); + p.Coins -= pk.Price; + Gamification.GrantXp(p, pk.Xp); + await Save(p); + await Ledger(uid, "xp", -pk.Price, id); + return (true, p, ""); + } + var list = kind switch { "avatar" => p.OwnedAvatars, diff --git a/src/app/globals.css b/src/app/globals.css index 2294349..1bcc24b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -45,6 +45,20 @@ --font-jakarta: "Plus Jakarta Sans Variable", system-ui, sans-serif; } +/* Premium (pro) perk: animated shimmering chat bubble. */ +@keyframes chatshimmer { + 0% { background-position: 0% 50%; } + 100% { background-position: 200% 50%; } +} +.premium-chat { + background: linear-gradient(90deg, #d4af37, #ffe9a8, #d4af37, #ffe9a8); + background-size: 200% 100%; + animation: chatshimmer 3s linear infinite; + color: #2a1f04; + font-weight: 600; + box-shadow: 0 0 10px rgba(212, 175, 55, 0.45); +} + html, body { height: 100%; diff --git a/src/components/screens/ChatScreen.tsx b/src/components/screens/ChatScreen.tsx index 1582f6a..44ec726 100644 --- a/src/components/screens/ChatScreen.tsx +++ b/src/components/screens/ChatScreen.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Send } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useOnlineStore } from "@/lib/online-store"; +import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { sound } from "@/lib/sound"; @@ -15,6 +16,7 @@ export function ChatScreen() { const messages = useOnlineStore((s) => s.chatMessages); const sendChat = useOnlineStore((s) => s.sendChat); const closeChat = useOnlineStore((s) => s.closeChat); + const isPro = useSessionStore((s) => s.profile?.plan === "pro"); const navBack = useUIStore((s) => s.back); const [text, setText] = useState(""); const endRef = useRef(null); @@ -77,7 +79,7 @@ export function ChatScreen() { className={cn( "max-w-[78%] rounded-2xl px-3.5 py-2 text-sm", m.fromMe - ? "ms-auto btn-gold rounded-ee-sm" + ? cn("ms-auto rounded-ee-sm", isPro ? "premium-chat" : "btn-gold") : "me-auto glass text-cream rounded-es-sm" )} > diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 6ac861c..57e458c 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -34,6 +34,8 @@ export function ShopScreen() { return profile.ownedCardBacks.includes(item.id); case "reactionpack": return profile.ownedReactionPacks.includes(item.id); + case "xp": + return false; // consumable — always buyable default: return profile.ownedStickerPacks.includes(item.id); } @@ -55,6 +57,7 @@ export function ShopScreen() { const cardbacks = items.filter((i) => i.kind === "cardback"); const reactions = items.filter((i) => i.kind === "reactionpack"); const stickers = items.filter((i) => i.kind === "stickerpack"); + const xp = items.filter((i) => i.kind === "xp"); return ( @@ -150,6 +153,21 @@ export function ShopScreen() { ))} + +
+

{t("shop.xpHint")}

+
+ {xp.map((item) => ( + buy(item)} + preview={} + /> + ))} +
+
); } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 6b4e7a1..212f5a3 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -258,6 +258,8 @@ const fa: Dict = { "shop.cardstyles": "طرح کارت‌ها", "shop.reactions": "بسته شکلک‌ها", "shop.stickers": "بسته استیکرها", + "shop.xp": "امتیاز تجربه (XP)", + "shop.xpHint": "افزایش سریع سطح — XP گران است", "reward.newTitle": "عنوان جدید", "reactions.title": "شکلک", @@ -521,6 +523,8 @@ const en: Dict = { "shop.cardstyles": "Card styles", "shop.reactions": "Reaction packs", "shop.stickers": "Sticker packs", + "shop.xp": "XP packs", + "shop.xpHint": "Level up faster — XP is expensive", "reward.newTitle": "New title", "reactions.title": "Emoji", diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index a581424..fe2fdc0 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -130,6 +130,13 @@ export function leagueById(id: string): MatchLeague { return MATCH_LEAGUES.find((l) => l.id === id) ?? MATCH_LEAGUES[0]; } +/** Coin-priced XP packs (XP is intentionally expensive). Server-authoritative. */ +export const XP_PACKS: { id: string; xp: number; price: number }[] = [ + { id: "xp1", xp: 200, price: 5000 }, + { id: "xp2", xp: 600, price: 12000 }, + { id: "xp3", xp: 1500, price: 25000 }, +]; + /* ------------------------------- XP ---------------------------------- */ /** Hard level cap. */ @@ -149,13 +156,13 @@ export function leagueXpFactor(stake: number): number { return 1; } +/** XP multiplier for premium (pro) players. */ +export const PREMIUM_XP_MULT = 1.5; + export function matchXp(summary: MatchSummary): number { - const base = - 40 + - (summary.won ? 80 : 0) + - summary.tricksWon * 5 + - (summary.kotFor ? 30 : 0); - return Math.round(base * leagueXpFactor(summary.stake)); + // Every game grants XP; the winner earns double. + const base = 40 + summary.tricksWon * 5 + (summary.kotFor ? 30 : 0); + return Math.round(base * (summary.won ? 2 : 1) * leagueXpFactor(summary.stake)); } export interface LevelProgress { @@ -557,7 +564,8 @@ export function applyMatchResult( const ratingAfter = Math.max(0, ratingBefore + rDelta); const cDelta = coinDelta(summary); - const xpGain = matchXp(summary); + // Premium (pro) players earn a multiple of XP. + const xpGain = Math.round(matchXp(summary) * (profile.plan === "pro" ? PREMIUM_XP_MULT : 1)); const lvl = addXp(profile.level, profile.xp, xpGain); const stats = applyStats(profile.stats, summary); diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index e447cdf..6602a5a 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -7,8 +7,11 @@ import { CARD_FRONTS, REACTION_PACKS, STICKER_PACKS, + XP_PACKS, + addXp, applyMatchResult, dailyRewardFor, + faNum, xpNeededForLevel, } from "./gamification"; import { @@ -879,7 +882,15 @@ export class MockOnlineService implements OnlineService { price: p.price, preview: p.stickers[0], // sticker id; ShopScreen renders via })); - return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems]; + const xpItems: ShopItem[] = XP_PACKS.map((x) => ({ + id: x.id, + kind: "xp", + nameFa: `${faNum(x.xp)} امتیاز تجربه`, + nameEn: `${x.xp} XP`, + price: x.price, + preview: "⚡", + })); + return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...xpItems]; } async buyItem(id: string) { @@ -887,6 +898,17 @@ export class MockOnlineService implements OnlineService { const items = await this.getShopItems(); const item = items.find((i) => i.id === id); if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" }; + + // XP packs are consumable — grant XP instead of adding to an owned list. + if (item.kind === "xp") { + if (p.coins < item.price) + return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; + const pack = XP_PACKS.find((x) => x.id === id)!; + const lvl = addXp(p.level, p.xp, pack.xp); + this.profile = { ...p, coins: p.coins - item.price, level: lvl.level, xp: lvl.xp }; + this.saveProfile(); + return { ok: true, profile: this.profile, messageFa: "امتیاز اضافه شد", messageEn: "XP added" }; + } const ownedMap: Record = { avatar: p.ownedAvatars, cardfront: p.ownedCardFronts, diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 83e74fe..7c9e0f6 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -379,7 +379,8 @@ export type ShopItemKind = | "cardfront" | "cardback" | "reactionpack" - | "stickerpack"; + | "stickerpack" + | "xp"; export interface ShopItem { id: string;