Match intro "players joining" loading screen + i18n fix; checkpoint
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s

- MatchIntroOverlay: UNO-style pre-game reveal — the 4 seats animate into the
  table (with "?" placeholders until each player's data streams in for live
  matches), a 3-2-1-GO countdown, then the table shows. Wired via game-store
  matchIntroPending/consumeIntro, rendered online-only in GameScreen.
- Fix: intro.found / intro.getReady / intro.go existed only in the Persian dict;
  added the English strings (would have shown raw keys to EN users).
- Checkpoint of the in-progress UI/social batch (CoinsPill, shop titles section,
  friend-request rate limit, etc.) — all green.

Verified: tsc + next build + scripts/sim.ts + dotnet build server/Hokm.slnx all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 21:58:54 +03:30
parent cb27a16dc1
commit 03dfbe1e67
16 changed files with 319 additions and 79 deletions
+7 -5
View File
@@ -203,9 +203,11 @@ export function GameTable({
{/* Your hand */}
<PlayerHand legalIds={legalIds} />
{/* Turn indicator */}
<TurnIndicator />
<TurnTimer />
{/* Turn indicator + timer — stacked so they never overlap */}
<div className="absolute bottom-[140px] sm:bottom-[172px] left-1/2 -translate-x-1/2 z-30 flex flex-col items-center gap-2.5 pointer-events-none">
<TurnTimer />
<TurnIndicator />
</div>
<DisconnectBanner />
<Reactions />
<ShortcutsHint />
@@ -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 ? (
<motion.div
@@ -702,7 +704,7 @@ function TurnTimer() {
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / turnMsForStake(stake, speed)));
const danger = secs <= 5;
return (
<div className="absolute bottom-[156px] sm:bottom-[190px] left-1/2 -translate-x-1/2 z-30 w-36 sm:w-40 text-center">
<div className="w-36 sm:w-40 text-center">
<span
className={cn(
"block text-sm font-black tabular-nums mb-1",
+57 -12
View File
@@ -27,6 +27,47 @@ interface Props {
front?: CardFront;
}
/* Pip positions (x,y as fractions of the card) for number cards 210. Bottom-half
pips (y>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<number, [number, number][]> = {
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 (
<div className="absolute inset-0 pointer-events-none">
{layout.map(([x, y], i) => (
<span
key={i}
className="absolute font-bold"
style={{
left: `${x * 100}%`,
top: `${y * 100}%`,
transform: `translate(-50%,-50%)${y > 0.5 ? " rotate(180deg)" : ""}`,
fontSize: size,
lineHeight: 1,
color,
}}
>
{symbol}
</span>
))}
</div>
);
}
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({
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
</div>
{/* Center symbol — large, bold, slightly shadowed */}
<div
className={cn("absolute inset-0 flex items-center justify-center font-black", s.center)}
style={{
color: inkColor,
textShadow: red
? "0 2px 10px rgba(210,40,40,0.18)"
: "0 2px 10px rgba(28,28,56,0.12)",
}}
>
{symbol}
</div>
{/* Center: pip layout for 210, big rank for J/Q/K, single big pip for Ace */}
{rank >= 2 && rank <= 10 ? (
<Pips rank={rank} symbol={symbol} color={pipColor} w={s.w} />
) : rank === 14 ? (
<div className="absolute inset-0 flex items-center justify-center font-black"
style={{ color: pipColor, textShadow: "0 2px 10px rgba(0,0,0,0.12)" }}>
<span style={{ fontSize: s.w * 0.54, lineHeight: 1 }}>{symbol}</span>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center font-black"
style={{ lineHeight: 0.9 }}>
<span style={{ fontSize: s.w * 0.46, color: inkColor }}>{label}</span>
<span style={{ fontSize: s.w * 0.27, color: pipColor, marginTop: s.w * -0.03 }}>{symbol}</span>
</div>
)}
{/* Bottom-right corner (rotated 180°) */}
<div
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { Coins, Plus } from "lucide-react";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
/**
* The coin balance, as a button that opens the buy-coins store. Use it anywhere
* coins are shown so tapping the balance always leads to topping up.
*/
export function CoinsPill({ className }: { className?: string }) {
const coins = useSessionStore((s) => s.profile?.coins ?? 0);
const go = useUIStore((s) => s.go);
const { t } = useI18n();
return (
<button
onClick={() => go("buycoins")}
title={t("buy.title")}
aria-label={t("buy.title")}
className={cn(
"glass rounded-full ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 py-1 flex items-center gap-1.5 hover:bg-navy-800/80 active:scale-95 transition",
className
)}
>
<Coins className="size-3.5 text-gold-400 shrink-0" />
<span className="text-xs font-bold text-gold-300 tabular-nums">{coins.toLocaleString()}</span>
<span className="grid size-5 place-items-center rounded-full btn-gold shrink-0">
<Plus className="size-3" strokeWidth={3.5} />
</span>
</button>
);
}
+150
View File
@@ -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<Seat, { pos: string; enter: { x?: number; y?: number }; delay: number }> = {
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 (
<motion.div
initial={{ opacity: 0, scale: 0.4, ...cfg.enter }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
transition={{ type: "spring", stiffness: 240, damping: 18, delay: cfg.delay }}
className={cn("absolute flex flex-col items-center gap-1 w-20", cfg.pos)}
>
<div
className={cn(
"relative size-14 rounded-full grid place-items-center text-2xl font-bold ring-2",
team === 0 ? "bg-teal-700/80 text-teal-100 ring-teal-400/70" : "bg-rose-900/70 text-rose-100 ring-rose-400/70"
)}
style={{ boxShadow: team === 0 ? "0 0 18px rgba(45,212,191,0.4)" : "0 0 18px rgba(251,113,133,0.4)" }}
>
{sp ? (
sp.avatar
) : (
<motion.span
className="text-cream/30 text-xl"
animate={{ opacity: [0.3, 0.7, 0.3] }}
transition={{ repeat: Infinity, duration: 1.2 }}
>
?
</motion.span>
)}
{seat === 0 && (
<span className="absolute -bottom-1.5 rounded-full btn-gold px-1.5 text-[8px] font-black leading-tight">
{t("match.you")}
</span>
)}
</div>
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">
{sp?.name ?? "…"}
</span>
{title && <span className="text-[8px] font-bold gold-text leading-none max-w-20 truncate">{title}</span>}
{sp && sp.level > 0 && (
<span className="text-[9px] text-gold-300/80 leading-none">{t("common.level")} {sp.level}</span>
)}
</motion.div>
);
}
/**
* 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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[75] flex flex-col items-center justify-center bg-navy-950/92 backdrop-blur-md persian-pattern"
>
<motion.h2
initial={{ scale: 0.6, opacity: 0, y: -12 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 16 }}
className="gold-text text-3xl font-black mb-1"
>
{t("intro.found")}
</motion.h2>
<p className="text-cream/55 text-sm mb-6">{t("intro.getReady")}</p>
{/* the table with the four seats sliding in */}
<div className="relative w-[min(86vw,360px)] aspect-square max-h-[46vh]">
{/* felt oval */}
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="felt absolute inset-[18%] rounded-[46%]"
/>
{/* center countdown */}
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<AnimatePresence mode="wait">
<motion.div
key={count}
initial={{ scale: 0.3, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 1.8, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 18 }}
className={cn(
"grid size-16 place-items-center rounded-full font-black",
count === 0 ? "btn-gold text-2xl px-2" : "glass gold-border text-4xl gold-text"
)}
>
{count >= 1 ? count : count === 0 ? t("intro.go") : ""}
</motion.div>
</AnimatePresence>
</div>
{([0, 1, 2, 3] as Seat[]).map((seat) => (
<Seat key={seat} seat={seat} />
))}
</div>
{/* team legend */}
<div className="mt-6 flex items-center gap-4 text-xs">
<span className="flex items-center gap-1.5 text-teal-300">
<span className="size-2.5 rounded-full bg-teal-400" /> {t("team.us")}
</span>
<span className="flex items-center gap-1.5 text-rose-300">
<span className="size-2.5 rounded-full bg-rose-400" /> {t("team.them")}
</span>
</div>
</motion.div>
);
}
+7 -9
View File
@@ -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() {
<Gift className="size-4 text-gold-400" />
</button>
<button
onClick={() => go("buycoins")}
className="glass rounded-full px-3 py-1.5 flex items-center gap-1.5 hover:bg-navy-800/80 transition"
title={t("buy.title")}
onClick={() => go("shop")}
className="glass rounded-full p-2 hover:bg-navy-800/80 transition"
title={t("menu.shop")}
>
<Coins className="size-4 text-gold-400" />
<span className="text-sm font-bold text-cream tabular-nums">
{profile.coins.toLocaleString()}
</span>
<span className="text-gold-400 text-sm leading-none font-bold">+</span>
<Store className="size-4 text-gold-400" />
</button>
<CoinsPill />
</div>
</div>
);
+11 -1
View File
@@ -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) */}
<AnimatePresence>
{introPending && mode === "online" && (
<MatchIntroOverlay onDone={consumeIntro} />
)}
</AnimatePresence>
{/* teammate asked to forfeit — confirm or decline */}
{forfeitRequest && (
<motion.div
+2 -9
View File
@@ -4,6 +4,7 @@ import { motion } from "framer-motion";
import { Coins, Lock, Trophy, Users } from "lucide-react";
import { useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { CoinsPill } from "@/components/online/CoinsPill";
import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification";
import { useOnlineStore } from "@/lib/online-store";
import { useSessionStore } from "@/lib/session-store";
@@ -43,15 +44,7 @@ export function OnlineLobbyScreen() {
return (
<ScreenShell>
<ScreenHeader
title={t("lobby.title")}
right={
<span className="glass rounded-full px-3 py-1.5 text-xs font-bold text-gold-300 flex items-center gap-1">
<Coins className="size-3.5 text-gold-400" />
{coins.toLocaleString()}
</span>
}
/>
<ScreenHeader title={t("lobby.title")} right={<CoinsPill />} />
{/* league pick (only for ranked) */}
<div className="glass rounded-2xl p-4 mb-4">
+3 -5
View File
@@ -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() {
<div className="mt-2 flex items-center justify-center gap-2">
<RankBadge rating={profile.rating} showRating />
<span className="glass rounded-full px-2.5 py-1 text-xs font-bold text-gold-300 flex items-center gap-1">
<Coins className="size-3.5 text-gold-400" />
{profile.coins.toLocaleString()}
</span>
<CoinsPill />
</div>
<div className="mt-4">
+2 -9
View File
@@ -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 (
<ScreenShell>
<ScreenHeader
title={t("shop.title")}
right={
<span className="glass rounded-full px-3 py-1.5 text-xs font-bold text-gold-300 flex items-center gap-1">
<Coins className="size-3.5 text-gold-400" />
{profile.coins.toLocaleString()}
</span>
}
/>
<ScreenHeader title={t("shop.title")} right={<CoinsPill />} />
{msg && (
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
+10
View File
@@ -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<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchIntroPending: false,
newMatch: (settings) => {
clearPending();
@@ -376,6 +381,7 @@ export const useGameStore = create<GameStore>((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<GameStore>((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<GameStore>((set, get) => {
set({ forfeitRequest: null });
},
consumeIntro: () => set({ matchIntroPending: false }),
reset: () => {
clearPending();
if (liveUnsub) {
@@ -553,6 +562,7 @@ export const useGameStore = create<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchIntroPending: false,
seatPlayers: [],
tally: freshTally(),
turnDeadline: null,
+7
View File
@@ -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",
+10 -10
View File
@@ -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<AchievementDef["metric"]>, 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;
+4 -4
View File
@@ -961,10 +961,10 @@ export class MockOnlineService implements OnlineService {
async getCoinPacks(): Promise<CoinPack[]> {
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 },
];
}