Shop: every item is coin-priced; level/rank/achievement only gate the purchase
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled

No more earned-only (rank/wins) cosmetics — every avatar, card back/front,
reaction & sticker pack now has a coin price. Rank/wins/achievement become
purchase requirements (coin · coin+rank · coin+rank+achievement), enforced
client (mock + ShopScreen lock label) and server (ProfileService.ItemGate,
keyed by kind:id). Ownership = default + purchased only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 21:27:25 +03:30
parent ccfc9b0536
commit 72efc03e2d
6 changed files with 137 additions and 78 deletions
@@ -139,6 +139,47 @@ public class ProfileService
return (0, 0);
}
// Per-item purchase gates for the named (non-tier-encoded) cosmetics, keyed by
// "kind:id" since ids repeat across kinds (e.g. "taunt" is both a reaction &
// sticker pack). Every one is still coin-priced — this only gates the purchase.
// ⚠️ Mirror of req* in src/lib/online/types.ts (AVATARS) + gamification.ts
// (CARD_BACKS/FRONTS, REACTION_PACKS, STICKER_PACKS). Keep both in sync.
private static readonly Dictionary<string, (int Level, int Rating, string? Ach)> ItemGate = new()
{
// avatars
["avatar:a-robot"] = (0, 0, "wins_50"),
["avatar:a-wizard"] = (0, 1300, null),
["avatar:a-ninja"] = (0, 0, "wins_100"),
["avatar:a-king"] = (0, 1500, null),
["avatar:a-genie"] = (0, 1700, null),
["avatar:a-crown"] = (0, 1900, "hakem_7"),
["avatar:a-gem"] = (0, 2100, "shutout_10"),
// card backs
["cardback:crimson"] = (0, 0, "wins_25"),
["cardback:ruby"] = (0, 1300, null),
["cardback:royal"] = (0, 0, "wins_50"),
["cardback:aurora"] = (0, 1500, null),
["cardback:obsidian"] = (0, 1700, null),
["cardback:imperial"] = (0, 1900, "hakem_7"),
// card fronts
["cardfront:parchment"] = (0, 1300, null),
["cardfront:mint"] = (0, 0, "wins_50"),
["cardfront:goldleaf"] = (0, 1500, null),
["cardfront:crystal"] = (0, 1700, null),
["cardfront:imperial-face"] = (0, 0, "wins_100"),
// reaction packs
["reactionpack:champion"] = (0, 1300, null),
["reactionpack:legend"] = (0, 0, "wins_100"),
// sticker packs
["stickerpack:hokm"] = (0, 0, "shutout_1"),
["stickerpack:persian"] = (0, 0, "wins_100"),
["stickerpack:taunt"] = (0, 0, "kot_25"),
["stickerpack:rulership"] = (0, 0, "hakem_7"),
["stickerpack:firestorm"] = (0, 0, "streak_10"),
["stickerpack:victory"] = (0, 1500, null),
["stickerpack:raghib"] = (0, 0, "kot_10"),
};
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
{
var p = await GetOrCreate(uid, null);
@@ -147,6 +188,13 @@ public class ProfileService
var gate = GiftGateFor(id);
if (p.Level < gate.Level || p.Rating < gate.Rating) return (false, p, "locked");
// Named-item gate (avatars/backs/fronts/reactions/stickers): coin-priced but
// locked until the level / rating / achievement requirement is met.
if (ItemGate.TryGetValue($"{kind}:{id}", out var ig) &&
(p.Level < ig.Level || p.Rating < ig.Rating ||
(ig.Ach != null && !p.Unlocked.Contains(ig.Ach))))
return (false, p, "locked");
// XP packs are consumable (grant XP, may level up) — not added to an owned list.
if (kind == "xp")
{