diff --git a/HANDOFF.md b/HANDOFF.md index dbacab1..7b15a22 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -63,7 +63,7 @@ npm run build # next static export - Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. **Matchmaking waits ~15s (randomized 12–18s) for humans, then bots fill** (`GameManager.NextQueueWaitMs`; mock mirrors it in `beginSearch`). MatchmakingScreen shows the elapsed timer + a bot-fill hint. - **Per-league turn time** (think faster in higher leagues): Starter/vs-AI/private → **15s**, Pro (stake ≥500) → **10s**, Expert (stake ≥1000) → **7s**. Single source: `turnMsForStake(stake, speed?)` in `gamification.ts`; the live server mirrors it in `GameRoom.TurnMs`. The turn-timer bar reads it from `matchMeta.stake`. - **Speed (Blitz) mode** — CLIENT-ONLY (vs-AI + private rooms; ranked stays standard). Flat **5s** turn clock (`SPEED_TURN_MS`), races to **5** points (`SPEED_TARGET_SCORE`), and ~½ pacing on animations/pauses (the `fast()` scaler in `game-store.scheduleAuto`). Threaded via `matchMeta.speed` + `GameSettings.speed`/`OnlineMatchConfig.speed`. Toggle on Home's vs-Computer card; a `SpeedBadge` (⚡) shows on the table HUD. No server change needed — private rooms are client-driven even in live mode, and ranked is intentionally excluded. -- **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose −stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven). Coin packs: starter 50k/95,000﷼, … Stores (Bazaar/Myket) must use their **IAB** (`/api/coins/iab/verify` scaffolded; token verification TODO). +- **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose −stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven) + store IAB. **⚠️ Economy is balanced as one system — keep ALL of these in sync client↔server when tuning** (`gamification.ts` ↔ `Profiles/Gamification.cs` + `ProfileService`): coin packs (`p1` 5,000c/99k﷼ · `p2` 12,000c/199k · `p3` 28,000c/399k · `p4` 65,000c/799k — a starter pack ≈ one premium cosmetic, NOT "buy everything"), item prices (cosmetics 600–6,000c, XP packs 1,500/4,000/8,000c), achievement coin reward `min(1500, max(50, round((40+goal·6)/50)·50))`, rank rewards 150/300/500/900/1500, daily `[100,150,200,300,400,600,1500]`. Stores (Bazaar/Myket) **IAB** — see §6. - **XP/levels:** every game grants XP, **winner ×2**; **premium (pro) ×1.5**; max level 100; curve `100*lvl + 15*lvl²`. **Store sells XP packs** (xp1 +200/5k, xp2 +600/12k, xp3 +1500/25k coins; consumable; unlocks level achievements). - **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "7–0 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**. - **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, **16 sticker packs** (all custom inline-SVG art in `Sticker.tsx`). **✨ Luxury tier** (premium giftable items): luxury avatars (🦢🎩💎💰🏆 + 💠 rank-gated), luxury card backs (Diamond/Black Gold/Platinum/Peacock/Rose-Gold) + fronts (Diamond/Black Gold), luxury titles (Hokm Sultan/Emperor/Grandmaster). Shop tags items priced ≥2000 with a gold **«ویژه/Luxury»** badge + ring. diff --git a/server/src/Hokm.Server/Profiles/Gamification.cs b/server/src/Hokm.Server/Profiles/Gamification.cs index 72091b7..20a105e 100644 --- a/server/src/Hokm.Server/Profiles/Gamification.cs +++ b/server/src/Hokm.Server/Profiles/Gamification.cs @@ -56,7 +56,7 @@ public static class Gamification // metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach. private record AchDef(string Id, string? Metric, int RatingFloor, int Goal, int Coin, string NameFa, string NameEn, string Icon); // Mirrors src/lib/online/gamification.ts (same ids/goals/coins/metrics). - private static int Coin(int g) => Math.Max(100, (int)Math.Floor((80.0 + g * 12) / 50.0 + 0.5) * 50); + private static int Coin(int g) => Math.Min(1500, Math.Max(50, (int)Math.Floor((40.0 + g * 6) / 50.0 + 0.5) * 50)); private static string Fa(int n) => new string(n.ToString().Select(c => c is >= '0' and <= '9' ? "۰۱۲۳۴۵۶۷۸۹"[c - '0'] : c).ToArray()); @@ -77,11 +77,11 @@ public static class Gamification l.AddRange(Tier("roundsWon", "rounds", "🎴", new[] { 25, 100, 250, 500, 1000, 2000, 5000 }, g => $"{Fa(g)} دست برده", g => $"{g} Rounds Won")); l.AddRange(Tier("tricks", "tricks", "🗂️", new[] { 50, 100, 250, 500, 1000, 2500, 5000, 10000 }, g => $"{Fa(g)} دست‌برد", g => $"{g} Tricks")); l.AddRange(Tier("losses", "grit", "🛡️", new[] { 10, 50, 100 }, g => $"{Fa(g)} باخت", g => $"{g} Losses")); - l.Add(new AchDef("reach_silver", null, 1100, 1, 200, "لیگ نقره", "Reach Silver", "🥈")); - l.Add(new AchDef("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇")); - l.Add(new AchDef("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️")); - l.Add(new AchDef("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠")); - l.Add(new AchDef("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑")); + l.Add(new AchDef("reach_silver", null, 1100, 1, 150, "لیگ نقره", "Reach Silver", "🥈")); + l.Add(new AchDef("reach_gold", null, 1300, 1, 300, "لیگ طلا", "Reach Gold", "🥇")); + l.Add(new AchDef("reach_platinum", null, 1500, 1, 500, "لیگ پلاتین", "Reach Platinum", "🛡️")); + l.Add(new AchDef("reach_diamond", null, 1700, 1, 900, "لیگ الماس", "Reach Diamond", "💠")); + l.Add(new AchDef("reach_master", null, 1900, 1, 1500, "لیگ استاد", "Reach Master", "👑")); return l.ToArray(); } diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index bd5a681..a2c7d0a 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -11,10 +11,10 @@ public class ProfileService public static readonly CoinPackDto[] Packs = { - new() { Id = "p1", Coins = 50000, Bonus = 0, PriceToman = 95000, Tag = "starter" }, - new() { Id = "p2", Coins = 120000, Bonus = 15000, PriceToman = 189000, Tag = "popular" }, - new() { Id = "p3", Coins = 300000, Bonus = 50000, PriceToman = 389000, Tag = "best" }, - new() { Id = "p4", Coins = 700000, Bonus = 150000, PriceToman = 790000 }, + new() { Id = "p1", Coins = 5000, Bonus = 0, PriceToman = 99000, Tag = "starter" }, + new() { Id = "p2", Coins = 11000, Bonus = 1000, PriceToman = 199000, Tag = "popular" }, + new() { Id = "p3", Coins = 24000, Bonus = 4000, PriceToman = 399000, Tag = "best" }, + new() { Id = "p4", Coins = 50000, Bonus = 15000, PriceToman = 799000 }, }; private static ProfileDto Default(string userId, string? name) => new() @@ -120,9 +120,9 @@ public class ProfileService // Coin-priced XP packs (XP is intentionally expensive). Server-authoritative. public static readonly Dictionary XpPacks = new() { - ["xp1"] = (5000, 200), - ["xp2"] = (12000, 600), - ["xp3"] = (25000, 1500), + ["xp1"] = (1500, 200), + ["xp2"] = (4000, 600), + ["xp3"] = (8000, 1500), }; public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price) @@ -165,7 +165,7 @@ public class ProfileService /* ----------------------------- daily ------------------------------ */ // Mirror the client DAILY_REWARDS (src/lib/online/gamification.ts) exactly. - private static readonly int[] DailyRewards = { 300, 500, 750, 1000, 1500, 2500, 7500 }; + private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 600, 1500 }; private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd"); public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid) diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index 7b92bf6..312249f 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -203,9 +203,11 @@ export function GameTable({ {/* Your hand */} - {/* Turn indicator */} - - + {/* Turn indicator + timer — stacked so they never overlap */} +
+ + +
@@ -666,7 +668,7 @@ function TurnIndicator() { animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.85, y: -8 }} transition={{ type: "spring", stiffness: 340, damping: 24 }} - className="absolute bottom-[136px] sm:bottom-[168px] left-1/2 -translate-x-1/2 z-30" + className="pointer-events-none" > {isYou ? ( +
0.5) render rotated 180°, like a real deck — so each rank looks distinct. */ +const PL = 0.3, PC = 0.5, PR = 0.7; +const PIP_LAYOUT: Record = { + 2: [[PC, 0.24], [PC, 0.76]], + 3: [[PC, 0.22], [PC, 0.5], [PC, 0.78]], + 4: [[PL, 0.26], [PR, 0.26], [PL, 0.74], [PR, 0.74]], + 5: [[PL, 0.26], [PR, 0.26], [PC, 0.5], [PL, 0.74], [PR, 0.74]], + 6: [[PL, 0.24], [PR, 0.24], [PL, 0.5], [PR, 0.5], [PL, 0.76], [PR, 0.76]], + 7: [[PL, 0.23], [PR, 0.23], [PC, 0.365], [PL, 0.5], [PR, 0.5], [PL, 0.77], [PR, 0.77]], + 8: [[PL, 0.23], [PR, 0.23], [PC, 0.365], [PL, 0.5], [PR, 0.5], [PC, 0.635], [PL, 0.77], [PR, 0.77]], + 9: [[PL, 0.22], [PR, 0.22], [PL, 0.41], [PR, 0.41], [PC, 0.5], [PL, 0.59], [PR, 0.59], [PL, 0.78], [PR, 0.78]], + 10: [[PL, 0.22], [PR, 0.22], [PC, 0.32], [PL, 0.41], [PR, 0.41], [PL, 0.59], [PR, 0.59], [PC, 0.68], [PL, 0.78], [PR, 0.78]], +}; + +function Pips({ rank, symbol, color, w }: { rank: number; symbol: string; color: string; w: number }) { + const layout = PIP_LAYOUT[rank]; + if (!layout) return null; + const size = (rank >= 9 ? 0.165 : rank >= 7 ? 0.19 : 0.22) * w; + return ( +
+ {layout.map(([x, y], i) => ( + 0.5 ? " rotate(180deg)" : ""}`, + fontSize: size, + lineHeight: 1, + color, + }} + > + {symbol} + + ))} +
+ ); +} + export function PlayingCard({ card, faceDown, @@ -81,6 +122,7 @@ export function PlayingCard({ const red = SUIT_IS_RED[card.suit]; const symbol = SUIT_SYMBOL[card.suit]; const label = rankLabel(card.rank); + const rank = card.rank; // UNO-style: suit-aware background const cardBg = front @@ -114,18 +156,21 @@ export function PlayingCard({
{symbol}
- {/* Center symbol — large, bold, slightly shadowed */} -
- {symbol} -
+ {/* Center: pip layout for 2–10, big rank for J/Q/K, single big pip for Ace */} + {rank >= 2 && rank <= 10 ? ( + + ) : rank === 14 ? ( +
+ {symbol} +
+ ) : ( +
+ {label} + {symbol} +
+ )} {/* Bottom-right corner (rotated 180°) */}
s.profile?.coins ?? 0); + const go = useUIStore((s) => s.go); + const { t } = useI18n(); + return ( + + ); +} diff --git a/src/components/online/MatchIntroOverlay.tsx b/src/components/online/MatchIntroOverlay.tsx new file mode 100644 index 0000000..c86c38a --- /dev/null +++ b/src/components/online/MatchIntroOverlay.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; +import { useGameStore } from "@/lib/game-store"; +import { useI18n } from "@/lib/i18n"; +import { teamOf, type Seat } from "@/lib/hokm/types"; +import { titleById } from "@/lib/online/gamification"; +import { cn } from "@/lib/cn"; + +// Where each seat sits around the table + which edge it slides in from. +const SEAT: Record = { + 2: { pos: "top-0 left-1/2 -translate-x-1/2", enter: { y: -50 }, delay: 0.15 }, // partner (top) + 1: { pos: "top-1/2 right-0 -translate-y-1/2", enter: { x: 50 }, delay: 0.3 }, // opponent (right) + 3: { pos: "top-1/2 left-0 -translate-y-1/2", enter: { x: -50 }, delay: 0.45 }, // opponent (left) + 0: { pos: "bottom-0 left-1/2 -translate-x-1/2", enter: { y: 50 }, delay: 0 }, // you (bottom) +}; + +function Seat({ seat }: { seat: Seat }) { + const sp = useGameStore((s) => s.seatPlayers[seat]); + const { t, locale } = useI18n(); + const team = teamOf(seat); + const cfg = SEAT[seat]; + const td = titleById(sp?.title); + const title = td ? (locale === "fa" ? td.nameFa : td.nameEn) : null; + + return ( + +
+ {sp ? ( + sp.avatar + ) : ( + + ? + + )} + {seat === 0 && ( + + {t("match.you")} + + )} +
+ + {sp?.name ?? "…"} + + {title && {title}} + {sp && sp.level > 0 && ( + {t("common.level")} {sp.level} + )} +
+ ); +} + +/** + * UNO-style pre-game reveal: players animate into their seats around the table, + * then a short 3-2-1 countdown, then the game shows. Plays once per fresh online + * match (see game-store `matchIntroPending`). + */ +export function MatchIntroOverlay({ onDone }: { onDone: () => void }) { + const { t } = useI18n(); + // 3 → 2 → 1 → 0 (GO!) → -1 (finish) + const [count, setCount] = useState(3); + + useEffect(() => { + if (count <= -1) { + onDone(); + return; + } + // Hold the first number a touch longer so the seats finish sliding in. + const id = setTimeout(() => setCount((c) => c - 1), count === 3 ? 1150 : 760); + return () => clearTimeout(id); + }, [count, onDone]); + + return ( + + + {t("intro.found")} + +

{t("intro.getReady")}

+ + {/* the table with the four seats sliding in */} +
+ {/* felt oval */} + + {/* center countdown */} +
+ + + {count >= 1 ? count : count === 0 ? t("intro.go") : ""} + + +
+ + {([0, 1, 2, 3] as Seat[]).map((seat) => ( + + ))} +
+ + {/* team legend */} +
+ + {t("team.us")} + + + {t("team.them")} + +
+
+ ); +} diff --git a/src/components/online/TopBar.tsx b/src/components/online/TopBar.tsx index c0964af..473bbe3 100644 --- a/src/components/online/TopBar.tsx +++ b/src/components/online/TopBar.tsx @@ -1,12 +1,13 @@ "use client"; -import { Bell, Coins, Crown, Gift } from "lucide-react"; +import { Bell, Crown, Gift, Store } from "lucide-react"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useNotifStore } from "@/lib/notification-store"; import { useI18n } from "@/lib/i18n"; import { MAX_LEVEL, xpNeededForLevel } from "@/lib/online/gamification"; import { Avatar } from "./Avatar"; +import { CoinsPill } from "./CoinsPill"; export function TopBar() { const profile = useSessionStore((s) => s.profile); @@ -68,16 +69,13 @@ export function TopBar() { +
); diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx index 3c4bb36..4b2872e 100644 --- a/src/components/screens/GameScreen.tsx +++ b/src/components/screens/GameScreen.tsx @@ -1,9 +1,10 @@ "use client"; -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { GameTable } from "@/components/GameTable"; import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal"; +import { MatchIntroOverlay } from "@/components/online/MatchIntroOverlay"; import { useGameStore } from "@/lib/game-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; @@ -24,6 +25,8 @@ export function GameScreen() { const forfeited = useGameStore((s) => s.forfeited); const forfeitRequest = useGameStore((s) => s.forfeitRequest); const respondForfeit = useGameStore((s) => s.respondForfeit); + const introPending = useGameStore((s) => s.matchIntroPending); + const consumeIntro = useGameStore((s) => s.consumeIntro); const returnTo = useUIStore((s) => s.returnTo); const go = useUIStore((s) => s.go); const refreshProfile = useSessionStore((s) => s.refreshProfile); @@ -139,6 +142,13 @@ export function GameScreen() { onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined} /> + {/* UNO-style "players joining the table" intro (online matches, once) */} + + {introPending && mode === "online" && ( + + )} + + {/* teammate asked to forfeit — confirm or decline */} {forfeitRequest && ( - - - {coins.toLocaleString()} - - } - /> + } /> {/* league pick (only for ranked) */}
diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index 51bdf1a..a9b9aba 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -1,10 +1,11 @@ "use client"; import { motion } from "framer-motion"; -import { Check, ChevronLeft, Coins, Crown, Eye, EyeOff, Lock, Music, Pencil, Star, Upload, Users, Volume2 } from "lucide-react"; +import { Check, ChevronLeft, Crown, Eye, EyeOff, Lock, Music, Pencil, Star, Upload, Users, Volume2 } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { RankBadge } from "@/components/online/RankBadge"; +import { CoinsPill } from "@/components/online/CoinsPill"; import { XpBar } from "@/components/online/XpBar"; import { Avatar } from "@/components/online/Avatar"; import { useSessionStore } from "@/lib/session-store"; @@ -138,10 +139,7 @@ export function ProfileScreen() {
- - - {profile.coins.toLocaleString()} - +
diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 95451da..c31995a 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -5,6 +5,7 @@ import { Check, Coins, Sparkles, X } from "lucide-react"; import { useEffect, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { Sticker } from "@/components/online/Sticker"; +import { CoinsPill } from "@/components/online/CoinsPill"; import { useSessionStore } from "@/lib/session-store"; import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; @@ -146,15 +147,7 @@ export function ShopScreen() { return ( - - - {profile.coins.toLocaleString()} - - } - /> + } /> {msg && (
{msg}
diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 2372ca4..e2fa461 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -94,6 +94,8 @@ interface GameStore { forfeited: boolean; /** a teammate is asking to forfeit and needs your confirmation. */ forfeitRequest: ForfeitRequest | null; + /** a fresh online match just started — play the "players joining the table" intro once. */ + matchIntroPending: boolean; newMatch: (settings: GameSettings) => void; newOnlineMatch: (cfg: OnlineMatchConfig) => void; @@ -109,6 +111,8 @@ interface GameStore { forfeit: () => void; /** Respond to a teammate's forfeit request. */ respondForfeit: (confirm: boolean) => void; + /** Mark the match-intro reveal as played (so it doesn't replay on resume). */ + consumeIntro: () => void; reset: () => void; } @@ -334,6 +338,7 @@ export const useGameStore = create((set, get) => { paused: false, forfeited: false, forfeitRequest: null, + matchIntroPending: false, newMatch: (settings) => { clearPending(); @@ -376,6 +381,7 @@ export const useGameStore = create((set, get) => { paused: false, forfeited: false, forfeitRequest: null, + matchIntroPending: true, matchMeta: { ranked: cfg.ranked, stake: cfg.stake, speed: !!cfg.speed }, tally: freshTally(), turnDeadline: null, @@ -410,6 +416,7 @@ export const useGameStore = create((set, get) => { paused: false, forfeited: false, forfeitRequest: null, + matchIntroPending: true, matchMeta: { ranked: true, stake: 0, speed: false }, tally: freshTally(), turnDeadline: null, @@ -529,6 +536,8 @@ export const useGameStore = create((set, get) => { set({ forfeitRequest: null }); }, + consumeIntro: () => set({ matchIntroPending: false }), + reset: () => { clearPending(); if (liveUnsub) { @@ -553,6 +562,7 @@ export const useGameStore = create((set, get) => { paused: false, forfeited: false, forfeitRequest: null, + matchIntroPending: false, seatPlayers: [], tally: freshTally(), turnDeadline: null, diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index ab00f44..197c73f 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -221,6 +221,9 @@ const fa: Dict = { "mm.found": "بازیکنان پیدا شدند!", "mm.ready": "آماده شروع", "mm.fillHint": "اگر بازیکن آنلاینی پیدا نشود، ربات‌ها جایگزین می‌شوند", + "intro.found": "بازیکنان آماده‌اند!", + "intro.getReady": "بازی در حال شروع است…", + "intro.go": "شروع!", "mm.cancel": "لغو", "mm.start": "ورود به بازی", @@ -379,6 +382,10 @@ const en: Dict = { "match.addFriend": "Add", "match.sent": "Sent", + "intro.found": "Players ready!", + "intro.getReady": "The game is starting…", + "intro.go": "GO!", + "seat.you": "You", "team.us": "Us", "team.them": "Them", diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index b3689f9..796990d 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -134,9 +134,9 @@ export function leagueById(id: string): MatchLeague { /** 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 }, + { id: "xp1", xp: 200, price: 1500 }, + { id: "xp2", xp: 600, price: 4000 }, + { id: "xp3", xp: 1500, price: 8000 }, ]; /* ------------------------------- XP ---------------------------------- */ @@ -250,7 +250,7 @@ function tier( category, metric, goal: g, - coinReward: Math.max(100, Math.round((80 + g * 12) / 50) * 50), + coinReward: Math.min(1500, Math.max(50, Math.round((40 + g * 6) / 50) * 50)), icon, nameFa: faName(faNum(g)), nameEn: enName(g), @@ -287,11 +287,11 @@ export const ACHIEVEMENTS: AchievementDef[] = [ (g) => `${g} باخت`, (g) => `${g} Losses`, (g) => `با وجود ${g} باخت ادامه دهید`, (g) => `Persevere through ${g} losses`), // ranks (explicit rating floors) - { id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 200, icon: "🥈", nameFa: "لیگ نقره", nameEn: "Reach Silver", descFa: "به لیگ نقره برسید", descEn: "Reach the Silver league" }, - { id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 500, icon: "🥇", nameFa: "لیگ طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league" }, - { id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 1000, icon: "🛡️", nameFa: "لیگ پلاتین", nameEn: "Reach Platinum", descFa: "به لیگ پلاتین برسید", descEn: "Reach the Platinum league" }, - { id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 2000, icon: "💠", nameFa: "لیگ الماس", nameEn: "Reach Diamond", descFa: "به لیگ الماس برسید", descEn: "Reach the Diamond league" }, - { id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 4000, icon: "👑", nameFa: "لیگ استاد", nameEn: "Reach Master", descFa: "به لیگ استاد برسید", descEn: "Reach the Master league" }, + { id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 150, icon: "🥈", nameFa: "لیگ نقره", nameEn: "Reach Silver", descFa: "به لیگ نقره برسید", descEn: "Reach the Silver league" }, + { id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 300, icon: "🥇", nameFa: "لیگ طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league" }, + { id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 500, icon: "🛡️", nameFa: "لیگ پلاتین", nameEn: "Reach Platinum", descFa: "به لیگ پلاتین برسید", descEn: "Reach the Platinum league" }, + { id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 900, icon: "💠", nameFa: "لیگ الماس", nameEn: "Reach Diamond", descFa: "به لیگ الماس برسید", descEn: "Reach the Diamond league" }, + { id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 1500, icon: "👑", nameFa: "لیگ استاد", nameEn: "Reach Master", descFa: "به لیگ استاد برسید", descEn: "Reach the Master league" }, ]; function metricValue(metric: NonNullable, stats: PlayerStats, level: number): number { @@ -753,7 +753,7 @@ export function applyMatchResult( /* --------------------------- Daily reward ---------------------------- */ -export const DAILY_REWARDS = [300, 500, 750, 1000, 1500, 2500, 7500]; +export const DAILY_REWARDS = [100, 150, 200, 300, 400, 600, 1500]; export function dailyRewardFor(day: number): number { return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100; diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 5895c04..624b567 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -961,10 +961,10 @@ export class MockOnlineService implements OnlineService { async getCoinPacks(): Promise { return [ - { id: "p1", coins: 50000, bonus: 0, priceToman: 95000, tag: "starter" }, - { id: "p2", coins: 120000, bonus: 15000, priceToman: 189000, tag: "popular" }, - { id: "p3", coins: 300000, bonus: 50000, priceToman: 389000, tag: "best" }, - { id: "p4", coins: 700000, bonus: 150000, priceToman: 790000 }, + { id: "p1", coins: 5000, bonus: 0, priceToman: 99000, tag: "starter" }, + { id: "p2", coins: 11000, bonus: 1000, priceToman: 199000, tag: "popular" }, + { id: "p3", coins: 24000, bonus: 4000, priceToman: 399000, tag: "best" }, + { id: "p4", coins: 50000, bonus: 15000, priceToman: 799000 }, ]; }