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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -379,7 +379,8 @@ export type ShopItemKind =
|
||||
| "cardfront"
|
||||
| "cardback"
|
||||
| "reactionpack"
|
||||
| "stickerpack";
|
||||
| "stickerpack"
|
||||
| "xp";
|
||||
|
||||
export interface ShopItem {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user