Store XP packs (expensive), winner 2x XP, premium perks
- 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:
@@ -41,10 +41,12 @@ public static class Gamification
|
|||||||
public const int MaxLevel = 100;
|
public const int MaxLevel = 100;
|
||||||
public static int XpForLevel(int level) => 100 * level + 15 * level * level;
|
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;
|
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)
|
public static int MatchXp(MatchSummaryDto s)
|
||||||
{
|
{
|
||||||
int b = 40 + (s.Won ? 80 : 0) + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
|
// Every game grants XP; the winner earns double.
|
||||||
return (int)Math.Round(b * LeagueXpFactor(s.Stake));
|
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.
|
// metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach.
|
||||||
@@ -138,6 +140,14 @@ public static class Gamification
|
|||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Grant raw XP to a profile (store XP packs); mutates level/xp, capped at 100.</summary>
|
||||||
|
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)
|
private static (int level, int xp, bool up) AddXp(int level, int xp, int gain)
|
||||||
{
|
{
|
||||||
bool up = false;
|
bool up = false;
|
||||||
@@ -154,7 +164,9 @@ public static class Gamification
|
|||||||
int rDelta = RatingDelta(s, p.Rating, oppRating);
|
int rDelta = RatingDelta(s, p.Rating, oppRating);
|
||||||
int ratingAfter = Math.Max(0, ratingBefore + rDelta);
|
int ratingAfter = Math.Max(0, ratingBefore + rDelta);
|
||||||
int cDelta = CoinDelta(s);
|
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;
|
var st = p.Stats;
|
||||||
int cur = s.Won ? st.CurrentWinStreak + 1 : 0;
|
int cur = s.Won ? st.CurrentWinStreak + 1 : 0;
|
||||||
@@ -209,7 +221,7 @@ public static class Gamification
|
|||||||
CoinsBefore = coinsBefore,
|
CoinsBefore = coinsBefore,
|
||||||
CoinsAfter = coinsAfter,
|
CoinsAfter = coinsAfter,
|
||||||
CoinsDelta = coinsAfter - coinsBefore,
|
CoinsDelta = coinsAfter - coinsBefore,
|
||||||
XpGained = MatchXp(s),
|
XpGained = xpGain,
|
||||||
LevelBefore = levelBefore,
|
LevelBefore = levelBefore,
|
||||||
LevelAfter = lvl.level,
|
LevelAfter = lvl.level,
|
||||||
LeveledUp = lvl.level > levelBefore,
|
LeveledUp = lvl.level > levelBefore,
|
||||||
|
|||||||
@@ -112,9 +112,30 @@ public class ProfileService
|
|||||||
|
|
||||||
/* ----------------------------- shop ------------------------------- */
|
/* ----------------------------- shop ------------------------------- */
|
||||||
|
|
||||||
|
// Coin-priced XP packs (XP is intentionally expensive). Server-authoritative.
|
||||||
|
public static readonly Dictionary<string, (int Price, int Xp)> 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)
|
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);
|
||||||
|
|
||||||
|
// 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
|
var list = kind switch
|
||||||
{
|
{
|
||||||
"avatar" => p.OwnedAvatars,
|
"avatar" => p.OwnedAvatars,
|
||||||
|
|||||||
@@ -45,6 +45,20 @@
|
|||||||
--font-jakarta: "Plus Jakarta Sans Variable", system-ui, sans-serif;
|
--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,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { ChevronLeft, ChevronRight, Send } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Send } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
@@ -15,6 +16,7 @@ export function ChatScreen() {
|
|||||||
const messages = useOnlineStore((s) => s.chatMessages);
|
const messages = useOnlineStore((s) => s.chatMessages);
|
||||||
const sendChat = useOnlineStore((s) => s.sendChat);
|
const sendChat = useOnlineStore((s) => s.sendChat);
|
||||||
const closeChat = useOnlineStore((s) => s.closeChat);
|
const closeChat = useOnlineStore((s) => s.closeChat);
|
||||||
|
const isPro = useSessionStore((s) => s.profile?.plan === "pro");
|
||||||
const navBack = useUIStore((s) => s.back);
|
const navBack = useUIStore((s) => s.back);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -77,7 +79,7 @@ export function ChatScreen() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[78%] rounded-2xl px-3.5 py-2 text-sm",
|
"max-w-[78%] rounded-2xl px-3.5 py-2 text-sm",
|
||||||
m.fromMe
|
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"
|
: "me-auto glass text-cream rounded-es-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function ShopScreen() {
|
|||||||
return profile.ownedCardBacks.includes(item.id);
|
return profile.ownedCardBacks.includes(item.id);
|
||||||
case "reactionpack":
|
case "reactionpack":
|
||||||
return profile.ownedReactionPacks.includes(item.id);
|
return profile.ownedReactionPacks.includes(item.id);
|
||||||
|
case "xp":
|
||||||
|
return false; // consumable — always buyable
|
||||||
default:
|
default:
|
||||||
return profile.ownedStickerPacks.includes(item.id);
|
return profile.ownedStickerPacks.includes(item.id);
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,7 @@ export function ShopScreen() {
|
|||||||
const cardbacks = items.filter((i) => i.kind === "cardback");
|
const cardbacks = items.filter((i) => i.kind === "cardback");
|
||||||
const reactions = items.filter((i) => i.kind === "reactionpack");
|
const reactions = items.filter((i) => i.kind === "reactionpack");
|
||||||
const stickers = items.filter((i) => i.kind === "stickerpack");
|
const stickers = items.filter((i) => i.kind === "stickerpack");
|
||||||
|
const xp = items.filter((i) => i.kind === "xp");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenShell>
|
<ScreenShell>
|
||||||
@@ -150,6 +153,21 @@ export function ShopScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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>
|
</ScreenShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ const fa: Dict = {
|
|||||||
"shop.cardstyles": "طرح کارتها",
|
"shop.cardstyles": "طرح کارتها",
|
||||||
"shop.reactions": "بسته شکلکها",
|
"shop.reactions": "بسته شکلکها",
|
||||||
"shop.stickers": "بسته استیکرها",
|
"shop.stickers": "بسته استیکرها",
|
||||||
|
"shop.xp": "امتیاز تجربه (XP)",
|
||||||
|
"shop.xpHint": "افزایش سریع سطح — XP گران است",
|
||||||
"reward.newTitle": "عنوان جدید",
|
"reward.newTitle": "عنوان جدید",
|
||||||
|
|
||||||
"reactions.title": "شکلک",
|
"reactions.title": "شکلک",
|
||||||
@@ -521,6 +523,8 @@ const en: Dict = {
|
|||||||
"shop.cardstyles": "Card styles",
|
"shop.cardstyles": "Card styles",
|
||||||
"shop.reactions": "Reaction packs",
|
"shop.reactions": "Reaction packs",
|
||||||
"shop.stickers": "Sticker packs",
|
"shop.stickers": "Sticker packs",
|
||||||
|
"shop.xp": "XP packs",
|
||||||
|
"shop.xpHint": "Level up faster — XP is expensive",
|
||||||
"reward.newTitle": "New title",
|
"reward.newTitle": "New title",
|
||||||
|
|
||||||
"reactions.title": "Emoji",
|
"reactions.title": "Emoji",
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ export function leagueById(id: string): MatchLeague {
|
|||||||
return MATCH_LEAGUES.find((l) => l.id === id) ?? MATCH_LEAGUES[0];
|
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 ---------------------------------- */
|
/* ------------------------------- XP ---------------------------------- */
|
||||||
|
|
||||||
/** Hard level cap. */
|
/** Hard level cap. */
|
||||||
@@ -149,13 +156,13 @@ export function leagueXpFactor(stake: number): number {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** XP multiplier for premium (pro) players. */
|
||||||
|
export const PREMIUM_XP_MULT = 1.5;
|
||||||
|
|
||||||
export function matchXp(summary: MatchSummary): number {
|
export function matchXp(summary: MatchSummary): number {
|
||||||
const base =
|
// Every game grants XP; the winner earns double.
|
||||||
40 +
|
const base = 40 + summary.tricksWon * 5 + (summary.kotFor ? 30 : 0);
|
||||||
(summary.won ? 80 : 0) +
|
return Math.round(base * (summary.won ? 2 : 1) * leagueXpFactor(summary.stake));
|
||||||
summary.tricksWon * 5 +
|
|
||||||
(summary.kotFor ? 30 : 0);
|
|
||||||
return Math.round(base * leagueXpFactor(summary.stake));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelProgress {
|
export interface LevelProgress {
|
||||||
@@ -557,7 +564,8 @@ export function applyMatchResult(
|
|||||||
const ratingAfter = Math.max(0, ratingBefore + rDelta);
|
const ratingAfter = Math.max(0, ratingBefore + rDelta);
|
||||||
|
|
||||||
const cDelta = coinDelta(summary);
|
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 lvl = addXp(profile.level, profile.xp, xpGain);
|
||||||
|
|
||||||
const stats = applyStats(profile.stats, summary);
|
const stats = applyStats(profile.stats, summary);
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import {
|
|||||||
CARD_FRONTS,
|
CARD_FRONTS,
|
||||||
REACTION_PACKS,
|
REACTION_PACKS,
|
||||||
STICKER_PACKS,
|
STICKER_PACKS,
|
||||||
|
XP_PACKS,
|
||||||
|
addXp,
|
||||||
applyMatchResult,
|
applyMatchResult,
|
||||||
dailyRewardFor,
|
dailyRewardFor,
|
||||||
|
faNum,
|
||||||
xpNeededForLevel,
|
xpNeededForLevel,
|
||||||
} from "./gamification";
|
} from "./gamification";
|
||||||
import {
|
import {
|
||||||
@@ -879,7 +882,15 @@ export class MockOnlineService implements OnlineService {
|
|||||||
price: p.price,
|
price: p.price,
|
||||||
preview: p.stickers[0], // sticker id; ShopScreen renders via <Sticker>
|
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) {
|
async buyItem(id: string) {
|
||||||
@@ -887,6 +898,17 @@ export class MockOnlineService implements OnlineService {
|
|||||||
const items = await this.getShopItems();
|
const items = await this.getShopItems();
|
||||||
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" };
|
||||||
|
|
||||||
|
// 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[]> = {
|
const ownedMap: Record<string, string[]> = {
|
||||||
avatar: p.ownedAvatars,
|
avatar: p.ownedAvatars,
|
||||||
cardfront: p.ownedCardFronts,
|
cardfront: p.ownedCardFronts,
|
||||||
|
|||||||
@@ -379,7 +379,8 @@ export type ShopItemKind =
|
|||||||
| "cardfront"
|
| "cardfront"
|
||||||
| "cardback"
|
| "cardback"
|
||||||
| "reactionpack"
|
| "reactionpack"
|
||||||
| "stickerpack";
|
| "stickerpack"
|
||||||
|
| "xp";
|
||||||
|
|
||||||
export interface ShopItem {
|
export interface ShopItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user