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
@@ -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,
};
/// <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)
{
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,
@@ -112,9 +112,30 @@ public class ProfileService
/* ----------------------------- 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)
{
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,