From 13ec0d4300ac684d0ca12aae23f10029b5bdd11c Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 10:49:54 +0330 Subject: [PATCH] Turn timer + auto-play, disconnect/reconnect, cosmetics, queue & paid plan - Turn timer (20s) for play/trump; system auto-plays a smart move on timeout - Disconnect handling (mock): wait-for-return countdown, system covers turns - Cosmetics: titles, card-back styles, custom profile-image upload, badges; pickers in Profile; shop sells card styles; reward modal shows new titles - Paid plan (pro): free players queue when server busy, pro skips; upgrade flow - OnlineService extended (upgradePlan, richer profile patch); mock implements queue + plans; gamification adds TITLES + CARD_STYLES Co-Authored-By: Claude Opus 4.8 --- scripts/sim.ts | 6 +- src/components/GameTable.tsx | 87 ++++++++++++- src/components/PlayingCard.tsx | 29 ++++- src/components/online/Avatar.tsx | 38 ++++++ .../online/PostMatchRewardsModal.tsx | 22 ++++ src/components/online/TopBar.tsx | 11 +- src/components/screens/MatchmakingScreen.tsx | 38 +++++- src/components/screens/ProfileScreen.tsx | 112 ++++++++++++++++- src/components/screens/ShopScreen.tsx | 15 ++- src/lib/game-store.ts | 90 ++++++++++++-- src/lib/i18n.tsx | 46 +++++++ src/lib/online/gamification.ts | 67 ++++++++++ src/lib/online/mock-service.ts | 117 ++++++++++++++---- src/lib/online/service.ts | 7 +- src/lib/online/types.ts | 48 ++++++- src/lib/session-store.ts | 10 +- 16 files changed, 682 insertions(+), 61 deletions(-) create mode 100644 src/components/online/Avatar.tsx diff --git a/scripts/sim.ts b/scripts/sim.ts index 07b8017..c2e4dd8 100644 --- a/scripts/sim.ts +++ b/scripts/sim.ts @@ -87,8 +87,12 @@ function baseProfile(): UserProfile { games: 0, wins: 0, losses: 0, kotsFor: 0, kotsAgainst: 0, tricks: 0, bestWinStreak: 0, currentWinStreak: 0, }, + plan: "free", ownedAvatars: ["a-fox"], - ownedThemes: ["royal"], + ownedCardStyles: ["classic"], + ownedTitles: ["novice"], + title: "novice", + cardStyle: "classic", achievements: {}, unlocked: [], createdAt: 0, diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index 187be79..b6b14a0 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -1,8 +1,9 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Crown, LogOut } from "lucide-react"; -import { useGameStore } from "@/lib/game-store"; +import { Crown, LogOut, WifiOff } from "lucide-react"; +import { useEffect, useState } from "react"; +import { TURN_MS, useGameStore } from "@/lib/game-store"; import { legalMoves } from "@/lib/hokm/engine"; import { sortHand } from "@/lib/hokm/deck"; import { @@ -15,9 +16,22 @@ import { teamOf, } from "@/lib/hokm/types"; import { useI18n } from "@/lib/i18n"; +import { useSessionStore } from "@/lib/session-store"; +import { cardStyleById } from "@/lib/online/gamification"; import { cn } from "@/lib/cn"; import { PlayingCard } from "./PlayingCard"; +function useCountdown(deadline: number | null) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (deadline == null) return; + const id = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(id); + }, [deadline]); + if (deadline == null) return null; + return Math.max(0, Math.ceil((deadline - now) / 1000)); +} + export function GameTable({ onExit }: { onExit?: () => void } = {}) { const game = useGameStore((s) => s.game); const reset = useGameStore((s) => s.reset); @@ -73,6 +87,8 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) { {/* Turn indicator */} + + {/* Overlays */} @@ -220,6 +236,9 @@ function OpponentHand({ horizontal?: boolean; }) { const count = useGameStore((s) => s.game.players[seat].hand.length); + const styleId = useSessionStore((s) => s.profile?.cardStyle ?? "classic"); + const cs = cardStyleById(styleId); + const back = { c1: cs.c1, c2: cs.c2, accent: cs.accent }; const cards = Array.from({ length: count }); return (
- +
))} @@ -387,6 +406,68 @@ function TurnIndicator() { ); } +/* ----------------------------- Turn timer ----------------------------- */ + +function TurnTimer() { + const deadline = useGameStore((s) => s.turnDeadline); + const phase = useGameStore((s) => s.game.phase); + const secs = useCountdown(deadline); + if (deadline == null || secs == null) return null; + if (phase !== "playing" && phase !== "choosing-trump") return null; + const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / TURN_MS)); + const danger = secs <= 5; + return ( +
+ + {secs} + +
+
+
+
+ ); +} + +function DisconnectBanner() { + const seat = useGameStore((s) => s.disconnectedSeat); + const deadline = useGameStore((s) => s.reconnectDeadline); + const name = useGameStore((s) => (seat != null ? s.seatPlayers[seat]?.name : null)); + const secs = useCountdown(deadline); + const { t } = useI18n(); + return ( + + {seat != null && ( + +
+ + + {t("dc.waiting", { name: name ?? "", s: secs ?? 0 })} + +
+
+ )} +
+ ); +} + /* ------------------------------ Overlays ------------------------------ */ function Backdrop({ children }: { children: React.ReactNode }) { diff --git a/src/components/PlayingCard.tsx b/src/components/PlayingCard.tsx index e601410..4be4f5d 100644 --- a/src/components/PlayingCard.tsx +++ b/src/components/PlayingCard.tsx @@ -11,12 +11,19 @@ const SIZES = { export type CardSize = keyof typeof SIZES; +interface CardBack { + c1: string; + c2: string; + accent: string; +} + interface Props { card?: Card; faceDown?: boolean; size?: CardSize; className?: string; dimmed?: boolean; + back?: CardBack; } export function PlayingCard({ @@ -25,18 +32,34 @@ export function PlayingCard({ size = "md", className, dimmed, + back, }: Props) { const s = SIZES[size]; if (faceDown || !card) { + const styled = back + ? { + width: s.w, + height: s.h, + borderRadius: 8, + background: `repeating-linear-gradient(45deg, ${back.accent}40 0 6px, transparent 6px 12px), linear-gradient(160deg, ${back.c1}, ${back.c2})`, + border: `1px solid ${back.accent}80`, + boxShadow: "0 6px 14px rgba(0,0,0,0.4)", + } + : { width: s.w, height: s.h }; return (
-
+
+ ✦ +
); diff --git a/src/components/online/Avatar.tsx b/src/components/online/Avatar.tsx new file mode 100644 index 0000000..3195690 --- /dev/null +++ b/src/components/online/Avatar.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { avatarEmoji } from "@/lib/online/types"; +import { cn } from "@/lib/cn"; + +export function Avatar({ + id, + image, + size = 40, + className, +}: { + id: string; + image?: string | null; + size?: number; + className?: string; +}) { + if (image) { + // eslint-disable-next-line @next/next/no-img-element + return ( + + ); + } + return ( + + {avatarEmoji(id)} + + ); +} diff --git a/src/components/online/PostMatchRewardsModal.tsx b/src/components/online/PostMatchRewardsModal.tsx index f9e6128..b0629c9 100644 --- a/src/components/online/PostMatchRewardsModal.tsx +++ b/src/components/online/PostMatchRewardsModal.tsx @@ -107,6 +107,28 @@ export function PostMatchRewardsModal({
)} + {reward.newTitles.length > 0 && ( +
+ {reward.newTitles.map((tt, i) => ( + + 🏷️ + + {t("reward.newTitle")} + + {locale === "fa" ? tt.nameFa : tt.nameEn} + + + + ))} +
+ )} + diff --git a/src/components/online/TopBar.tsx b/src/components/online/TopBar.tsx index 77f5286..f7b506d 100644 --- a/src/components/online/TopBar.tsx +++ b/src/components/online/TopBar.tsx @@ -1,10 +1,10 @@ "use client"; -import { Coins, Gift } from "lucide-react"; +import { Coins, Crown, Gift } from "lucide-react"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; -import { avatarEmoji } from "@/lib/online/types"; +import { Avatar } from "./Avatar"; export function TopBar() { const profile = useSessionStore((s) => s.profile); @@ -19,12 +19,13 @@ export function TopBar() { onClick={() => go("profile")} className="glass rounded-full ltr:pr-4 rtl:pl-4 ltr:pl-1.5 rtl:pr-1.5 py-1.5 flex items-center gap-2 hover:bg-navy-800/80 transition" > - - {avatarEmoji(profile.avatar)} + + - + {profile.displayName} + {profile.plan === "pro" && } {t("common.level")} {profile.level} diff --git a/src/components/screens/MatchmakingScreen.tsx b/src/components/screens/MatchmakingScreen.tsx index 18d8f9a..8513245 100644 --- a/src/components/screens/MatchmakingScreen.tsx +++ b/src/components/screens/MatchmakingScreen.tsx @@ -1,10 +1,11 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Loader2 } from "lucide-react"; +import { Crown, Loader2 } from "lucide-react"; import { ScreenShell } from "@/components/online/ScreenHeader"; import { useGameStore } from "@/lib/game-store"; import { useOnlineStore } from "@/lib/online-store"; +import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; @@ -15,10 +16,12 @@ export function MatchmakingScreen() { const mm = useOnlineStore((s) => s.matchmaking); const cancelMatchmaking = useOnlineStore((s) => s.cancelMatchmaking); const newOnlineMatch = useGameStore((s) => s.newOnlineMatch); + const upgradePlan = useSessionStore((s) => s.upgradePlan); const goGame = useUIStore((s) => s.goGame); const go = useUIStore((s) => s.go); const ready = mm.phase === "ready"; + const queued = mm.phase === "queued"; const slots = [0, 1, 2, 3]; const cancel = async () => { @@ -42,6 +45,39 @@ export function MatchmakingScreen() { goGame("home"); }; + if (queued) { + return ( + +
+
+

{t("queue.title")}

+

{t("queue.busy")}

+
+ {mm.queuePosition ?? 0} +
+

{t("queue.position", { n: mm.queuePosition ?? 0 })}

+ +
+ + {t("queue.skip")} + +
+
+
+ ); + } + return (
diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index abaac61..1a1c250 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -1,42 +1,74 @@ "use client"; -import { Check, Coins, Pencil } from "lucide-react"; -import { useState } from "react"; +import { Check, Coins, Crown, Pencil, Upload } from "lucide-react"; +import { useRef, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { RankBadge } from "@/components/online/RankBadge"; import { XpBar } from "@/components/online/XpBar"; +import { Avatar } from "@/components/online/Avatar"; import { useSessionStore } from "@/lib/session-store"; import { useI18n } from "@/lib/i18n"; -import { ACHIEVEMENTS, achievementProgress } from "@/lib/online/gamification"; -import { AVATARS, avatarEmoji } from "@/lib/online/types"; +import { + ACHIEVEMENTS, + CARD_STYLES, + TITLES, + achievementProgress, +} from "@/lib/online/gamification"; +import { AVATARS } from "@/lib/online/types"; import { cn } from "@/lib/cn"; export function ProfileScreen() { const { t, locale } = useI18n(); const profile = useSessionStore((s) => s.profile); const updateProfile = useSessionStore((s) => s.updateProfile); + const upgradePlan = useSessionStore((s) => s.upgradePlan); + const fileRef = useRef(null); const [editing, setEditing] = useState(false); const [name, setName] = useState(profile?.displayName ?? ""); if (!profile) return null; const s = profile.stats; const winrate = s.games > 0 ? Math.round((s.wins / s.games) * 100) : 0; + const titleDef = TITLES.find((x) => x.id === profile.title); + const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null; const saveName = async () => { if (name.trim()) await updateProfile({ displayName: name.trim() }); setEditing(false); }; + const onUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => updateProfile({ avatarImage: String(reader.result) }); + reader.readAsDataURL(file); + }; + return ( {/* identity */}
-
- {avatarEmoji(profile.avatar)} +
+
+ +
+ +
+ {titleName && ( +
{titleName}
+ )} + {editing ? (
+ +
+ {profile.plan === "pro" ? ( + + + {t("plan.active")} + + ) : ( + + )} +
{/* avatar picker */} @@ -93,6 +142,57 @@ export function ProfileScreen() {
+ {/* title picker */} +
+

{t("profile.titleLabel")}

+
+ {TITLES.filter((tt) => profile.ownedTitles.includes(tt.id)).map((tt) => ( + + ))} +
+
+ + {/* card style picker */} +
+

{t("profile.cardStyleLabel")}

+
+ {CARD_STYLES.filter((c) => profile.ownedCardStyles.includes(c.id)).map((c) => ( + + ))} +
+
+ {/* stats */}

{t("profile.stats")}

diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 5c7315d..d56b691 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -25,7 +25,7 @@ export function ShopScreen() { const owns = (item: ShopItem) => item.kind === "avatar" ? profile.ownedAvatars.includes(item.id) - : profile.ownedThemes.includes(item.id); + : profile.ownedCardStyles.includes(item.id); const buy = async (item: ShopItem) => { const res = await getService().buyItem(item.id); @@ -38,7 +38,7 @@ export function ShopScreen() { }; const avatars = items.filter((i) => i.kind === "avatar"); - const themes = items.filter((i) => i.kind === "theme"); + const cardstyles = items.filter((i) => i.kind === "cardstyle"); return ( @@ -64,9 +64,9 @@ export function ShopScreen() {
-
+
- {themes.map((item) => ( + {cardstyles.map((item) => ( buy(item)} preview={ } /> diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 28403b2..c361aea 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -11,7 +11,7 @@ import { selectHakem, startNextRound, } from "./hokm/engine"; -import { Card, GameState, RoundResult, Suit } from "./hokm/types"; +import { Card, GameState, RoundResult, Seat, Suit } from "./hokm/types"; import { avatarEmoji } from "./online/types"; const KOT_POINTS = 2; @@ -25,6 +25,13 @@ export const TIMING = { roundPause: 2600, } as const; +/** How long a player has to act before the system plays for them. */ +export const TURN_MS = 20000; +/** Grace period to wait for a disconnected player to return. */ +export const RECONNECT_MS = 15000; +/** Per-turn chance an online opponent briefly drops (mock). */ +const DISCONNECT_CHANCE = 0.07; + export type GameMode = "ai" | "online"; export interface SeatPlayer { @@ -59,6 +66,12 @@ interface GameStore { matchMeta: { ranked: boolean; stake: number }; tally: MatchTally; + /** epoch ms by which the current actor must act (for the turn-timer UI). */ + turnDeadline: number | null; + /** a seat that has dropped and we're waiting on (online). */ + disconnectedSeat: Seat | null; + reconnectDeadline: number | null; + newMatch: (settings: GameSettings) => void; newOnlineMatch: (cfg: OnlineMatchConfig) => void; chooseTrump: (suit: Suit) => void; @@ -93,12 +106,21 @@ export const useGameStore = create((set, get) => { }); } + function playSeatAI(seat: Seat) { + const cur = get().game; + if (cur.phase !== "playing" || cur.turn !== seat) return; + const card = chooseCardAI(cur, seat); + set({ game: playCard(cur, seat, card) }); + scheduleAuto(); + } + function scheduleAuto() { clearPending(); const g = get().game; switch (g.phase) { case "selecting-hakem": + set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { set({ game: dealForTrump(get().game) }); scheduleAuto(); @@ -107,7 +129,18 @@ export const useGameStore = create((set, get) => { case "choosing-trump": { const hakem = g.hakem!; - if (!g.players[hakem].isHuman) { + if (g.players[hakem].isHuman) { + // human hakem: timed choice, system auto-picks on timeout + set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null }); + pending = setTimeout(() => { + const cur = get().game; + if (cur.phase !== "choosing-trump") return; + const suit = chooseTrumpAI(cur.players[cur.hakem!].hand); + set({ game: engineChooseTrump(cur, suit), turnDeadline: null }); + scheduleAuto(); + }, TURN_MS); + } else { + set({ turnDeadline: null }); pending = setTimeout(() => { const cur = get().game; const suit = chooseTrumpAI(cur.players[cur.hakem!].hand); @@ -120,29 +153,53 @@ export const useGameStore = create((set, get) => { case "playing": { const seat = g.turn!; - if (!g.players[seat].isHuman) { + if (g.players[seat].isHuman) { + // human turn: timed; system plays a smart legal move on timeout + set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { const cur = get().game; - const s = cur.turn!; - const card = chooseCardAI(cur, s); - set({ game: playCard(cur, s, card) }); + if (cur.phase !== "playing" || cur.turn !== seat) return; + set({ game: playCard(cur, seat, chooseCardAI(cur, seat)), turnDeadline: null }); scheduleAuto(); - }, TIMING.aiPlay); + }, TURN_MS); + } else { + const st = get(); + if ( + st.mode === "online" && + st.disconnectedSeat == null && + Math.random() < DISCONNECT_CHANCE + ) { + // simulate this opponent dropping; wait for them, then they return + set({ + turnDeadline: null, + disconnectedSeat: seat, + reconnectDeadline: Date.now() + RECONNECT_MS, + }); + const back = Math.floor(RECONNECT_MS * (0.4 + Math.random() * 0.45)); + pending = setTimeout(() => { + set({ disconnectedSeat: null, reconnectDeadline: null }); + playSeatAI(seat); + }, back); + } else { + set({ turnDeadline: null }); + pending = setTimeout(() => playSeatAI(seat), TIMING.aiPlay); + } } break; } case "trick-complete": + set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { const next = advanceAfterTrick(get().game, KOT_POINTS); set({ game: next }); - // record the round once when it finalizes into match-over if (next.phase === "match-over") recordRound(next.lastRoundResult); scheduleAuto(); }, TIMING.trickPause); break; case "round-over": + set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { recordRound(get().game.lastRoundResult); set({ game: startNextRound(get().game) }); @@ -151,6 +208,7 @@ export const useGameStore = create((set, get) => { break; default: + set({ turnDeadline: null }); break; } } @@ -162,6 +220,9 @@ export const useGameStore = create((set, get) => { seatPlayers: [], matchMeta: { ranked: false, stake: 0 }, tally: freshTally(), + turnDeadline: null, + disconnectedSeat: null, + reconnectDeadline: null, newMatch: (settings) => { clearPending(); @@ -172,6 +233,9 @@ export const useGameStore = create((set, get) => { mode: "ai", matchMeta: { ranked: false, stake: 0 }, tally: freshTally(), + turnDeadline: null, + disconnectedSeat: null, + reconnectDeadline: null, seatPlayers: settings.names.map((name, i) => ({ name, avatar: AI_AVATARS[i], @@ -191,6 +255,9 @@ export const useGameStore = create((set, get) => { mode: "online", matchMeta: { ranked: cfg.ranked, stake: cfg.stake }, tally: freshTally(), + turnDeadline: null, + disconnectedSeat: null, + reconnectDeadline: null, seatPlayers: cfg.players.map((p) => ({ name: p.displayName, avatar: avatarEmoji(p.avatar), @@ -203,14 +270,14 @@ export const useGameStore = create((set, get) => { chooseTrump: (suit) => { const g = get().game; if (g.phase !== "choosing-trump") return; - set({ game: engineChooseTrump(g, suit) }); + set({ game: engineChooseTrump(g, suit), turnDeadline: null }); scheduleAuto(); }, playHuman: (card) => { const g = get().game; if (g.phase !== "playing" || g.turn !== 0) return; - set({ game: playCard(g, 0, card) }); + set({ game: playCard(g, 0, card), turnDeadline: null }); scheduleAuto(); }, @@ -222,6 +289,9 @@ export const useGameStore = create((set, get) => { mode: "ai", seatPlayers: [], tally: freshTally(), + turnDeadline: null, + disconnectedSeat: null, + reconnectDeadline: null, }); }, }; diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 020bd70..a6c2a3e 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -196,6 +196,29 @@ const fa: Dict = { "daily.come": "فردا برگردید", "rank.label": "لیگ", + + "dc.waiting": "{name} قطع شد — منتظر بازگشت ({s})", + + "profile.titleLabel": "عنوان", + "profile.cardStyleLabel": "طرح کارت", + "profile.image": "تصویر پروفایل", + "profile.upload": "آپلود تصویر", + "profile.plan": "اشتراک", + + "plan.pro": "ویژه", + "plan.free": "رایگان", + "plan.upgrade": "ارتقا به ویژه", + "plan.proDesc": "بازی بدون صف، در هر زمان", + "plan.active": "اشتراک ویژه فعال است", + + "queue.title": "در صف بازی", + "queue.busy": "سرور شلوغ است", + "queue.position": "نفر {n} در صف", + "queue.skip": "با اشتراک ویژه بدون صف وارد شوید", + "queue.upgrade": "ورود سریع (ویژه)", + + "shop.cardstyles": "طرح کارت‌ها", + "reward.newTitle": "عنوان جدید", }; const en: Dict = { @@ -381,6 +404,29 @@ const en: Dict = { "daily.come": "Come back tomorrow", "rank.label": "League", + + "dc.waiting": "{name} disconnected — waiting ({s})", + + "profile.titleLabel": "Title", + "profile.cardStyleLabel": "Card style", + "profile.image": "Profile image", + "profile.upload": "Upload image", + "profile.plan": "Plan", + + "plan.pro": "Pro", + "plan.free": "Free", + "plan.upgrade": "Upgrade to Pro", + "plan.proDesc": "Skip the queue, play anytime", + "plan.active": "Pro plan active", + + "queue.title": "In queue", + "queue.busy": "Server is busy", + "queue.position": "{n} in line", + "queue.skip": "Go Pro to skip the queue", + "queue.upgrade": "Skip queue (Pro)", + + "shop.cardstyles": "Card styles", + "reward.newTitle": "New title", }; const DICTS: Record = { fa, en }; diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index f18b095..c35a9d2 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -4,12 +4,15 @@ import { AchievementDef, AchievementUnlock, + CardStyleDef, LeagueInfo, MatchSummary, PlayerStats, RankTier, RankTierId, RewardResult, + TitleDef, + TitleUnlock, UserProfile, } from "./types"; @@ -176,6 +179,55 @@ export function achievementProgress( } } +/* ------------------------------ Titles ------------------------------- */ + +export const TITLES: TitleDef[] = [ + { id: "novice", nameFa: "تازه‌کار", nameEn: "Novice", hintFa: "پیش‌فرض", hintEn: "Default" }, + { id: "winner", nameFa: "برنده", nameEn: "Winner", hintFa: "۱۰ برد", hintEn: "10 wins" }, + { id: "kot_master", nameFa: "استاد کُت", nameEn: "Kot Master", hintFa: "۱۰ کُت", hintEn: "10 kots" }, + { id: "veteran", nameFa: "کهنه‌کار", nameEn: "Veteran", hintFa: "سطح ۲۰", hintEn: "Level 20" }, + { id: "champion", nameFa: "قهرمان", nameEn: "Champion", hintFa: "لیگ طلا", hintEn: "Gold league" }, + { id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" }, +]; + +export function titleUnlocked( + id: string, + stats: PlayerStats, + rating: number, + level: number +): boolean { + switch (id) { + case "novice": + return true; + case "winner": + return stats.wins >= 10; + case "kot_master": + return stats.kotsFor >= 10; + case "veteran": + return level >= 20; + case "champion": + return rating >= tierById("gold").floor; + case "legend": + return rating >= tierById("master").floor; + default: + return false; + } +} + +/* ---------------------------- Card styles ---------------------------- */ + +export const CARD_STYLES: CardStyleDef[] = [ + { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0 }, + { id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800 }, + { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 800 }, + { id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000 }, + { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 1500 }, +]; + +export function cardStyleById(id: string): CardStyleDef { + return CARD_STYLES.find((c) => c.id === id) ?? CARD_STYLES[0]; +} + /* ---------------------- Apply a match result ------------------------- */ function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats { @@ -239,6 +291,19 @@ export function applyMatchResult( const coinsAfter = Math.max(0, coinsBefore + cDelta + achievementCoins); + // Titles unlocked by the new state. + const ownedTitles = [...(profile.ownedTitles ?? [])]; + const newTitles: TitleUnlock[] = []; + for (const tdef of TITLES) { + if ( + titleUnlocked(tdef.id, stats, ratingAfter, lvl.level) && + !ownedTitles.includes(tdef.id) + ) { + ownedTitles.push(tdef.id); + newTitles.push({ id: tdef.id, nameFa: tdef.nameFa, nameEn: tdef.nameEn }); + } + } + const leagueBefore = getLeagueInfo(ratingBefore); const leagueAfter = getLeagueInfo(ratingAfter); const tierIndex = (id: RankTierId) => RANK_TIERS.findIndex((t) => t.id === id); @@ -256,6 +321,7 @@ export function applyMatchResult( stats, achievements, unlocked, + ownedTitles, }; const reward: RewardResult = { @@ -270,6 +336,7 @@ export function applyMatchResult( levelAfter: lvl.level, leveledUp: lvl.level > levelBefore, newAchievements, + newTitles, promoted, demoted, }; diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 29ad613..6d84c38 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -2,7 +2,7 @@ // Simulates remote players, friends presence, room invites and matchmaking // with timers, and computes rewards via gamification.ts. -import { applyMatchResult, dailyRewardFor } from "./gamification"; +import { CARD_STYLES, applyMatchResult, dailyRewardFor } from "./gamification"; import { CreateRoomOptions, MatchmakingOptions, @@ -94,7 +94,7 @@ function defaultProfile(session: AuthSession): UserProfile { username: "player_" + session.userId.slice(-4), displayName: "بازیکن", avatar: AVATARS[0].id, - phone: session.method === "phone" ? undefined : undefined, + plan: "free", level: 1, xp: 0, coins: 1000, @@ -110,13 +110,29 @@ function defaultProfile(session: AuthSession): UserProfile { currentWinStreak: 0, }, ownedAvatars: [AVATARS[0].id, AVATARS[1].id], - ownedThemes: ["royal"], + ownedCardStyles: ["classic"], + ownedTitles: ["novice"], + title: "novice", + cardStyle: "classic", achievements: {}, unlocked: [], createdAt: Date.now(), }; } +/** Backfill fields on older persisted profiles so the app never crashes. */ +function migrateProfile(p: UserProfile): UserProfile { + return { + ...p, + plan: p.plan ?? "free", + ownedAvatars: p.ownedAvatars ?? [AVATARS[0].id], + ownedCardStyles: p.ownedCardStyles ?? ["classic"], + ownedTitles: p.ownedTitles ?? ["novice"], + title: p.title ?? "novice", + cardStyle: p.cardStyle ?? "classic", + }; +} + function makeFriend(status?: PresenceStatus): Friend { return { id: rid("fr"), @@ -147,6 +163,7 @@ export class MockOnlineService implements OnlineService { | null = null; private currentOppRating = 1000; private lastOtp = ""; + private mmOpts: MatchmakingOptions | null = null; private messages: Record = {}; private unread: Record = {}; @@ -159,7 +176,8 @@ export class MockOnlineService implements OnlineService { constructor() { this.session = load(LS.session); - this.profile = load(LS.profile); + const loaded = load(LS.profile); + this.profile = loaded ? migrateProfile(loaded) : null; this.messages = load>(LS.chats) ?? {}; this.seedFriends(); } @@ -282,27 +300,40 @@ export class MockOnlineService implements OnlineService { async getProfile() { if (!this.profile) { - // guest fallback profile (not persisted as session) - this.profile = - load(LS.profile) ?? - defaultProfile({ - userId: rid("guest"), - token: "", - method: "guest", - createdAt: Date.now(), - }); + const loaded = load(LS.profile); + this.profile = loaded + ? migrateProfile(loaded) + : defaultProfile({ + userId: rid("guest"), + token: "", + method: "guest", + createdAt: Date.now(), + }); this.saveProfile(); } return this.profile; } - async updateProfile(patch: Partial>) { + async updateProfile( + patch: Partial< + Pick + > + ) { const p = await this.getProfile(); this.profile = { ...p, ...patch }; this.saveProfile(); return this.profile; } + async upgradePlan(): Promise { + const p = await this.getProfile(); + this.profile = { ...p, plan: "pro", planUntil: Date.now() + 30 * 864e5 }; + this.saveProfile(); + // pro players skip the queue immediately + if (this.matchmaking.phase === "queued") this.beginSearch(); + return this.profile; + } + /* ----------------------------- friends ----------------------------- */ async listFriends() { @@ -536,6 +567,44 @@ export class MockOnlineService implements OnlineService { async startMatchmaking(opts: MatchmakingOptions) { await this.getProfile(); + this.mmOpts = opts; + const me = this.profile!; + const pro = me.plan === "pro"; + const busy = Math.random() < 0.7; + + if (!pro && busy) { + // server is busy and the player is on the free plan → queue them + let pos = randInt(3, 8); + this.matchmaking = { + phase: "queued", + players: [{ id: me.id, displayName: me.displayName, avatar: me.avatar, level: me.level, rating: me.rating }], + elapsedMs: 0, + ranked: opts.ranked, + stake: opts.stake, + queuePosition: pos, + }; + this.emitMM(); + const tick = () => + this.after(1100, () => { + if (this.matchmaking.phase !== "queued") return; + pos -= 1; + if (pos <= 0) { + this.beginSearch(); + } else { + this.matchmaking.queuePosition = pos; + this.emitMM(); + tick(); + } + }); + tick(); + return; + } + + this.beginSearch(); + } + + private beginSearch() { + const opts = this.mmOpts!; const me = this.profile!; this.matchmaking = { phase: "searching", @@ -646,12 +715,15 @@ export class MockOnlineService implements OnlineService { price: 500 + i * 150, preview: a.emoji, })); - const themes: ShopItem[] = [ - { id: "midnight", kind: "theme", nameFa: "تم نیمه‌شب", nameEn: "Midnight", price: 1200, preview: "#0a142e" }, - { id: "emerald", kind: "theme", nameFa: "تم زمرد", nameEn: "Emerald", price: 1500, preview: "#0d6b6b" }, - { id: "crimson", kind: "theme", nameFa: "تم یاقوت", nameEn: "Crimson", price: 1800, preview: "#7f1d2e" }, - ]; - return [...avatarItems, ...themes]; + const cardItems: ShopItem[] = CARD_STYLES.filter((c) => c.price > 0).map((c) => ({ + id: c.id, + kind: "cardstyle", + nameFa: c.nameFa, + nameEn: c.nameEn, + price: c.price, + preview: c.accent, + })); + return [...avatarItems, ...cardItems]; } async buyItem(id: string) { @@ -660,7 +732,7 @@ export class MockOnlineService implements OnlineService { const item = items.find((i) => i.id === id); if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" }; const owned = - item.kind === "avatar" ? p.ownedAvatars.includes(id) : p.ownedThemes.includes(id); + item.kind === "avatar" ? p.ownedAvatars.includes(id) : p.ownedCardStyles.includes(id); if (owned) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" }; if (p.coins < item.price) return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; @@ -669,7 +741,8 @@ export class MockOnlineService implements OnlineService { ...p, coins: p.coins - item.price, ownedAvatars: item.kind === "avatar" ? [...p.ownedAvatars, id] : p.ownedAvatars, - ownedThemes: item.kind === "theme" ? [...p.ownedThemes, id] : p.ownedThemes, + ownedCardStyles: + item.kind === "cardstyle" ? [...p.ownedCardStyles, id] : p.ownedCardStyles, }; this.saveProfile(); return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" }; diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index e17cb6f..b0fd2c9 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -44,7 +44,12 @@ export interface OnlineService { /* ----- profile ----- */ getProfile(): Promise; - updateProfile(patch: Partial>): Promise; + updateProfile( + patch: Partial< + Pick + > + ): Promise; + upgradePlan(): Promise; /* ----- friends ----- */ listFriends(): Promise; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index f5cf239..3d374bd 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -28,22 +28,35 @@ export interface PlayerStats { currentWinStreak: number; } +export type PlanId = "free" | "pro"; + export interface UserProfile { id: string; username: string; displayName: string; avatar: string; // avatar id (see AVATARS) + avatarImage?: string; // custom uploaded image (data URL); overrides avatar phone?: string; email?: string; + plan: PlanId; + /** epoch ms when a pro plan expires (mock: far future) */ + planUntil?: number; + level: number; xp: number; // xp within the current level coins: number; rating: number; // competitive rating stats: PlayerStats; + + // cosmetics ownedAvatars: string[]; - ownedThemes: string[]; + ownedCardStyles: string[]; + ownedTitles: string[]; + title: string | null; // equipped title id + cardStyle: string; // equipped card-back style id + achievements: Record; // achievementId -> progress count unlocked: string[]; // achievementId list already unlocked @@ -98,6 +111,27 @@ export interface AchievementView extends AchievementDef { unlocked: boolean; } +/* ----------------------- Titles & card styles ------------------------ */ + +export interface TitleDef { + id: string; + nameFa: string; + nameEn: string; + /** how it's unlocked (for display) */ + hintFa: string; + hintEn: string; +} + +export interface CardStyleDef { + id: string; + nameFa: string; + nameEn: string; + c1: string; // back gradient start + c2: string; // back gradient end + accent: string; // pattern/border accent + price: number; // 0 = free/default +} + /* ------------------------------ Friends ------------------------------ */ export type PresenceStatus = "online" | "offline" | "in-game"; @@ -152,6 +186,7 @@ export interface Room { export type MatchmakingPhase = | "idle" + | "queued" // server busy, free plan waiting in line | "searching" | "found" | "ready" @@ -170,6 +205,8 @@ export interface MatchmakingState { elapsedMs: number; ranked: boolean; stake: number; + /** position in the queue when phase === "queued" */ + queuePosition?: number; } /* ------------------------- Match + Rewards --------------------------- */ @@ -193,6 +230,12 @@ export interface AchievementUnlock { coinReward: number; } +export interface TitleUnlock { + id: string; + nameFa: string; + nameEn: string; +} + export interface RewardResult { ratingBefore: number; ratingAfter: number; @@ -205,6 +248,7 @@ export interface RewardResult { levelAfter: number; leveledUp: boolean; newAchievements: AchievementUnlock[]; + newTitles: TitleUnlock[]; promoted: boolean; demoted: boolean; } @@ -223,7 +267,7 @@ export interface LeaderboardEntry { /* ------------------------------- Shop -------------------------------- */ -export type ShopItemKind = "avatar" | "theme"; +export type ShopItemKind = "avatar" | "cardstyle"; export interface ShopItem { id: string; diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts index 42b5294..3bb754d 100644 --- a/src/lib/session-store.ts +++ b/src/lib/session-store.ts @@ -21,7 +21,10 @@ interface SessionStore { signInGoogle: () => Promise; signOut: () => Promise; - updateProfile: (patch: Partial>) => Promise; + updateProfile: ( + patch: Partial> + ) => Promise; + upgradePlan: () => Promise; } export const useSessionStore = create((set, get) => ({ @@ -84,4 +87,9 @@ export const useSessionStore = create((set, get) => ({ const profile = await getService().updateProfile(patch); set({ profile }); }, + + upgradePlan: async () => { + const profile = await getService().upgradePlan(); + set({ profile }); + }, }));