Store XP packs (expensive), winner 2x XP, premium perks
CI/CD / CI - API (dotnet build + engine sim) (push) Failing after 1m40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m20s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 00:08:19 +03:30
parent 4199a82c9d
commit fd33f85e9c
9 changed files with 116 additions and 14 deletions
+14
View File
@@ -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%;
+3 -1
View File
@@ -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<HTMLDivElement>(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"
)}
>
+18
View File
@@ -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 (
<ScreenShell>
@@ -150,6 +153,21 @@ export function ShopScreen() {
))}
</div>
</Section>
<Section title={t("shop.xp")}>
<p className="text-[11px] text-cream/45 -mt-2 mb-3">{t("shop.xpHint")}</p>
<div className="grid grid-cols-3 gap-3">
{xp.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={false}
onBuy={() => buy(item)}
preview={<span className="text-4xl"></span>}
/>
))}
</div>
</Section>
</ScreenShell>
);
}
+4
View File
@@ -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",
+15 -7
View File
@@ -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);
+23 -1
View File
@@ -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 <Sticker>
}));
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<string, string[]> = {
avatar: p.ownedAvatars,
cardfront: p.ownedCardFronts,
+2 -1
View File
@@ -379,7 +379,8 @@ export type ShopItemKind =
| "cardfront"
| "cardback"
| "reactionpack"
| "stickerpack";
| "stickerpack"
| "xp";
export interface ShopItem {
id: string;