Build Hokm card game: offline vs-AI + online social/gamification (mock backend)

- Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots
- Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand)
- Online platform behind OnlineService seam (mock now, .NET SignalR later):
  auth (phone OTP + email/Google), profiles, friends, private rooms with
  partner pick, ranked matchmaking, leaderboard, shop
- Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements
- i18n fa/en, PWA manifest, engine + gamification sims

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 10:11:00 +03:30
parent dff1a34f95
commit e2d0a602b6
41 changed files with 5766 additions and 93 deletions
+141 -13
View File
@@ -1,26 +1,154 @@
@import "tailwindcss";
/*
FlatRender Hokm — "Persian Luxury" theme.
Deep navy/teal table, gold filigree accents, geometric motifs.
*/
:root {
--background: #ffffff;
--foreground: #171717;
--navy-950: #060c1f;
--navy-900: #0a142e;
--navy-800: #0e1c3f;
--navy-700: #14274f;
--teal-700: #0d6b6b;
--teal-500: #14b8a6;
--teal-400: #2dd4bf;
--gold-600: #b8860b;
--gold-500: #d4af37;
--gold-400: #e6c659;
--gold-300: #f1da8a;
--cream: #f5ecd6;
--background: var(--navy-950);
--foreground: #eef2f8;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-navy-950: var(--navy-950);
--color-navy-900: var(--navy-900);
--color-navy-800: var(--navy-800);
--color-navy-700: var(--navy-700);
--color-teal-700: var(--teal-700);
--color-teal-500: var(--teal-500);
--color-teal-400: var(--teal-400);
--color-gold-600: var(--gold-600);
--color-gold-500: var(--gold-500);
--color-gold-400: var(--gold-400);
--color-gold-300: var(--gold-300);
--color-cream: var(--cream);
--font-sans: var(--font-vazir), var(--font-jakarta), system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
html,
body {
height: 100%;
}
body {
background: var(--background);
background:
radial-gradient(1200px 800px at 50% -10%, rgba(20, 184, 166, 0.12), transparent 60%),
radial-gradient(900px 700px at 50% 120%, rgba(212, 175, 55, 0.08), transparent 55%),
var(--navy-950);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
/* Persian geometric motif — subtle tiled background */
.persian-pattern {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80'%3E%3Cg fill='none' stroke='%23d4af37' stroke-opacity='0.06' stroke-width='1'%3E%3Cpath d='M40 0 L80 40 L40 80 L0 40 Z'/%3E%3Cpath d='M40 16 L64 40 L40 64 L16 40 Z'/%3E%3Ccircle cx='40' cy='40' r='6'/%3E%3C/g%3E%3C/svg%3E");
background-size: 80px 80px;
}
/* Felt table surface */
.felt {
background:
radial-gradient(ellipse at 50% 45%, rgba(45, 212, 191, 0.18), transparent 62%),
radial-gradient(ellipse at 50% 50%, var(--teal-700) 0%, #0a3a3a 45%, #06201f 100%);
box-shadow:
inset 0 0 120px rgba(0, 0, 0, 0.55),
inset 0 0 0 2px rgba(212, 175, 55, 0.25),
0 30px 80px rgba(0, 0, 0, 0.6);
}
.gold-text {
background: linear-gradient(180deg, var(--gold-300), var(--gold-500) 55%, var(--gold-600));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.gold-border {
border: 1px solid rgba(212, 175, 55, 0.45);
}
.glass {
background: rgba(10, 20, 46, 0.72);
backdrop-filter: blur(14px);
border: 1px solid rgba(212, 175, 55, 0.18);
}
.btn-gold {
background: linear-gradient(180deg, var(--gold-400), var(--gold-600));
color: #2a1f04;
font-weight: 700;
box-shadow: 0 8px 24px rgba(212, 175, 55, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.4);
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
}
.btn-gold:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow: 0 12px 30px rgba(212, 175, 55, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.btn-gold:active {
transform: translateY(0);
}
/* Card face */
.card-face {
background: linear-gradient(160deg, #fffdf7, #f3ead2);
border: 1px solid rgba(0, 0, 0, 0.12);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}
.card-back {
background:
repeating-linear-gradient(45deg, rgba(212, 175, 55, 0.22) 0 6px, transparent 6px 12px),
linear-gradient(160deg, var(--navy-700), var(--navy-900));
border: 1px solid rgba(212, 175, 55, 0.45);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.4), inset 0 0 0 2px rgba(212, 175, 55, 0.25);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.3);
border-radius: 8px;
}
[dir="rtl"] {
font-family: var(--font-vazir), system-ui, sans-serif;
}
@keyframes float-up {
from {
transform: translateY(110vh);
}
to {
transform: translateY(-20vh);
}
}
.float-suit {
animation-name: float-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.float-suit {
animation: none;
display: none;
}
}
+29 -15
View File
@@ -1,33 +1,47 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { Metadata, Viewport } from "next";
import { Vazirmatn, Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
import { I18nProvider } from "@/lib/i18n";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
const vazir = Vazirmatn({
variable: "--font-vazir",
subsets: ["arabic", "latin"],
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const jakarta = Plus_Jakarta_Sans({
variable: "--font-jakarta",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "حکم | Hokm — بازی کارت ایرانی",
description: "بازی حکم اصیل ایرانی با حریف‌های هوشمند — Persian Hokm card game",
manifest: "/manifest.webmanifest",
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "حکم" },
};
export const viewport: Viewport = {
themeColor: "#060c1f",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
}: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="fa"
dir="rtl"
className={`${vazir.variable} ${jakarta.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full">
<I18nProvider>{children}</I18nProvider>
</body>
</html>
);
}
+55 -61
View File
@@ -1,65 +1,59 @@
import Image from "next/image";
"use client";
import { useEffect } from "react";
import { HomeScreen } from "@/components/HomeScreen";
import { GameScreen } from "@/components/screens/GameScreen";
import { ProfileScreen } from "@/components/screens/ProfileScreen";
import { FriendsScreen } from "@/components/screens/FriendsScreen";
import { OnlineLobbyScreen } from "@/components/screens/OnlineLobbyScreen";
import { RoomScreen } from "@/components/screens/RoomScreen";
import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen";
import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen";
import { ShopScreen } from "@/components/screens/ShopScreen";
import { AuthScreen } from "@/components/screens/AuthScreen";
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
export default function Page() {
const screen = useUIStore((s) => s.screen);
const init = useSessionStore((s) => s.init);
const loading = useSessionStore((s) => s.loading);
useEffect(() => {
init();
}, [init]);
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<>
{renderScreen(screen)}
<DailyRewardModal />
{loading && null}
</>
);
}
function renderScreen(screen: string) {
switch (screen) {
case "game":
return <GameScreen />;
case "auth":
return <AuthScreen />;
case "profile":
return <ProfileScreen />;
case "friends":
return <FriendsScreen />;
case "online":
return <OnlineLobbyScreen />;
case "room":
return <RoomScreen />;
case "matchmaking":
return <MatchmakingScreen />;
case "leaderboard":
return <LeaderboardScreen />;
case "shop":
return <ShopScreen />;
default:
return <HomeScreen />;
}
}
+572
View File
@@ -0,0 +1,572 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, LogOut } from "lucide-react";
import { useGameStore } from "@/lib/game-store";
import { legalMoves } from "@/lib/hokm/engine";
import { sortHand } from "@/lib/hokm/deck";
import {
Card,
Seat,
Suit,
SUITS,
SUIT_IS_RED,
SUIT_SYMBOL,
teamOf,
} from "@/lib/hokm/types";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { PlayingCard } from "./PlayingCard";
export function GameTable({ onExit }: { onExit?: () => void } = {}) {
const game = useGameStore((s) => s.game);
const reset = useGameStore((s) => s.reset);
const mode = useGameStore((s) => s.mode);
const { t } = useI18n();
const exit = onExit ?? reset;
const { phase, players, hakem, trump, turn, currentTrick } = game;
const legalIds = new Set(
phase === "playing" && turn === 0
? legalMoves(game, 0).map((c) => c.id)
: []
);
return (
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
{/* Top HUD */}
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between p-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-2">
{trump && <TrumpBadge trump={trump} />}
<button
onClick={exit}
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
title={t("hud.quit")}
>
<LogOut className="size-4 text-cream/80" />
</button>
</div>
</div>
{/* Felt table */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="felt relative w-[min(94vw,1100px)] h-[min(82vh,720px)] rounded-[42%]">
{/* opponent + partner seats */}
<SeatAvatar seat={2} className="absolute top-3 left-1/2 -translate-x-1/2" />
<SeatAvatar seat={1} className="absolute top-1/2 right-3 -translate-y-1/2" />
<SeatAvatar seat={3} className="absolute top-1/2 left-3 -translate-y-1/2" />
{/* opponents' face-down hands */}
<OpponentHand seat={2} className="absolute top-20 left-1/2 -translate-x-1/2" horizontal />
<OpponentHand seat={1} className="absolute top-1/2 right-16 -translate-y-1/2" />
<OpponentHand seat={3} className="absolute top-1/2 left-16 -translate-y-1/2" />
{/* center trick area */}
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} />
</div>
</div>
{/* Your hand */}
<PlayerHand legalIds={legalIds} />
{/* Turn indicator */}
<TurnIndicator />
{/* Overlays */}
<AnimatePresence>
{phase === "selecting-hakem" && <HakemOverlay key="hakem" />}
{phase === "choosing-trump" && players[hakem!]?.isHuman && (
<TrumpChooser key="trump" />
)}
{phase === "round-over" && <RoundOverlay key="round" />}
{phase === "match-over" && mode === "ai" && (
<MatchOverlay key="match" onExit={exit} />
)}
</AnimatePresence>
</main>
);
}
/* ----------------------------- Scoreboard ----------------------------- */
function Scoreboard() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
return (
<div className="glass rounded-2xl px-4 py-2.5 flex items-center gap-4">
<ScoreCol
label={t("team.us")}
tricks={game.roundTricks[0]}
score={game.matchScore[0]}
accent="text-teal-400"
/>
<div className="text-cream/30 text-lg font-light">/</div>
<ScoreCol
label={t("team.them")}
tricks={game.roundTricks[1]}
score={game.matchScore[1]}
accent="text-rose-400"
/>
<div className="ltr:ml-2 rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-3 rtl:pr-3">
<div className="text-[10px] text-cream/50">{t("home.target")}</div>
<div className="gold-text font-bold text-center">{game.targetScore}</div>
</div>
</div>
);
}
function ScoreCol({
label,
tricks,
score,
accent,
}: {
label: string;
tricks: number;
score: number;
accent: string;
}) {
const { t } = useI18n();
return (
<div className="text-center min-w-14">
<div className={cn("text-xs font-semibold", accent)}>{label}</div>
<div className="text-2xl font-black leading-none">{score}</div>
<div className="text-[10px] text-cream/45 mt-0.5">
{t("score.tricks")}: {tricks}
</div>
</div>
);
}
/* ----------------------------- Trump badge ---------------------------- */
function TrumpBadge({ trump }: { trump: Suit }) {
const { t } = useI18n();
const red = SUIT_IS_RED[trump];
return (
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
className="glass rounded-2xl px-3 py-2 flex items-center gap-2"
>
<span className="text-[10px] text-gold-400 font-semibold">
{t("trump.label")}
</span>
<span
className={cn(
"text-2xl leading-none",
red ? "text-rose-400" : "text-cream"
)}
>
{SUIT_SYMBOL[trump]}
</span>
</motion.div>
);
}
/* ----------------------------- Seat avatar ---------------------------- */
function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
const game = useGameStore((s) => s.game);
const sp = useGameStore((s) => s.seatPlayers[seat]);
const player = game.players[seat];
const active =
(game.phase === "playing" && game.turn === seat) ||
(game.phase === "choosing-trump" && game.hakem === seat);
const isHakem = game.hakem === seat;
const team = teamOf(seat);
const name = sp?.name ?? player.name;
return (
<div className={cn("flex flex-col items-center gap-1", className)}>
<motion.div
animate={
active
? { boxShadow: "0 0 0 3px rgba(212,175,55,0.9), 0 0 24px rgba(212,175,55,0.5)" }
: { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" }
}
className={cn(
"relative size-12 rounded-full flex items-center justify-center font-bold text-xl",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
)}
>
{sp?.avatar ?? name.charAt(0)}
{isHakem && (
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
)}
</motion.div>
<span className="text-[11px] text-cream/80 max-w-20 truncate">{name}</span>
{sp && sp.level > 0 && (
<span className="text-[9px] text-gold-400/80 leading-none">
{team === 0 ? "" : ""}
{`Lv ${sp.level}`}
</span>
)}
</div>
);
}
/* --------------------------- Opponent hands --------------------------- */
function OpponentHand({
seat,
className,
horizontal,
}: {
seat: Seat;
className?: string;
horizontal?: boolean;
}) {
const count = useGameStore((s) => s.game.players[seat].hand.length);
const cards = Array.from({ length: count });
return (
<div
className={cn(
"flex",
horizontal ? "flex-row" : "flex-col",
className
)}
>
{cards.map((_, i) => (
<div
key={i}
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
>
<PlayingCard faceDown size="sm" />
</div>
))}
</div>
);
}
/* ----------------------------- Trick area ----------------------------- */
const TRICK_OFFSET: Record<Seat, { x: number; y: number }> = {
0: { x: 0, y: 70 },
1: { x: 96, y: 0 },
2: { x: 0, y: -70 },
3: { x: -96, y: 0 },
};
const TRICK_ENTER: Record<Seat, { x: number; y: number }> = {
0: { x: 0, y: 260 },
1: { x: 360, y: 0 },
2: { x: 0, y: -260 },
3: { x: -360, y: 0 },
};
function TrickArea({
trick,
winner,
phase,
}: {
trick: { seat: Seat; card: Card }[];
winner: Seat | null;
phase: string;
}) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1 ">
<AnimatePresence>
{trick.map((pc) => {
const off = TRICK_OFFSET[pc.seat];
const enter = TRICK_ENTER[pc.seat];
const isWinner =
phase === "trick-complete" && winner === pc.seat;
return (
<motion.div
key={pc.card.id}
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
animate={{
x: off.x,
y: off.y,
opacity: 1,
scale: isWinner ? 1.12 : 1,
}}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{
filter: isWinner
? "drop-shadow(0 0 14px rgba(212,175,55,0.9))"
: undefined,
}}
>
<PlayingCard card={pc.card} size="md" />
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
}
/* ----------------------------- Player hand ---------------------------- */
function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const hand = useGameStore((s) => s.game.players[0].hand);
const phase = useGameStore((s) => s.game.phase);
const turn = useGameStore((s) => s.game.turn);
const playHuman = useGameStore((s) => s.playHuman);
const sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0;
const n = sorted.length;
return (
<div className="absolute bottom-0 inset-x-0 z-20 flex justify-center pb-3 pointer-events-none">
<div className="relative flex items-end justify-center pointer-events-auto">
{sorted.map((card, i) => {
const playable = myTurn && legalIds.has(card.id);
const dimmed = myTurn && !playable;
const mid = (n - 1) / 2;
const rot = (i - mid) * 3.2;
const lift = Math.abs(i - mid) * 4;
return (
<motion.button
key={card.id}
layout
initial={{ y: 120, opacity: 0 }}
animate={{ y: lift, opacity: 1, rotate: rot }}
transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.015 }}
whileHover={playable ? { y: lift - 26, scale: 1.06, zIndex: 50 } : {}}
onClick={() => playable && playHuman(card)}
disabled={!playable}
data-card={card.id}
data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : -22 }}
className={cn(
"origin-bottom",
playable && "cursor-pointer",
!myTurn && "cursor-default"
)}
>
<PlayingCard
card={card}
size="lg"
dimmed={dimmed}
className={cn(playable && "ring-2 ring-gold-400/70")}
/>
</motion.button>
);
})}
</div>
</div>
);
}
/* --------------------------- Turn indicator --------------------------- */
function TurnIndicator() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
if (game.phase !== "playing" || game.turn == null) return null;
const isYou = game.turn === 0;
const name = game.players[game.turn].name;
return (
<AnimatePresence mode="wait">
<motion.div
key={game.turn}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute bottom-[150px] left-1/2 -translate-x-1/2 z-30"
>
<div
className={cn(
"rounded-full px-4 py-1.5 text-sm font-semibold glass",
isYou ? "text-gold-300" : "text-cream/70"
)}
>
{isYou ? t("turn.you") : t("turn.other", { name })}
</div>
</motion.div>
</AnimatePresence>
);
}
/* ------------------------------ Overlays ------------------------------ */
function Backdrop({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 flex items-center justify-center bg-navy-950/70 backdrop-blur-sm p-5"
>
{children}
</motion.div>
);
}
function HakemOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const hakemName = game.hakem != null ? game.players[game.hakem].name : "";
return (
<Backdrop>
<motion.div
initial={{ scale: 0.9, y: 10 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
>
<h2 className="gold-text text-2xl font-black">{t("hakem.title")}</h2>
<p className="text-cream/60 text-sm mt-1">{t("hakem.desc")}</p>
<div className="flex flex-wrap justify-center gap-1.5 mt-5">
{game.hakemDraw.map((pc, i) => (
<motion.div
key={pc.card.id}
initial={{ opacity: 0, y: -20, rotateY: 90 }}
animate={{ opacity: 1, y: 0, rotateY: 0 }}
transition={{ delay: i * 0.12 }}
>
<PlayingCard card={pc.card} size="sm" />
</motion.div>
))}
</div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: game.hakemDraw.length * 0.12 + 0.2 }}
className="mt-5 text-gold-300 font-bold text-lg flex items-center justify-center gap-2"
>
<Crown className="size-5 text-gold-400 fill-gold-500" />
{t("hakem.is", { name: hakemName })}
</motion.p>
</motion.div>
</Backdrop>
);
}
function TrumpChooser() {
const choose = useGameStore((s) => s.chooseTrump);
const { t } = useI18n();
return (
<Backdrop>
<motion.div
initial={{ scale: 0.9, y: 10 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
>
<h2 className="gold-text text-2xl font-black">{t("trump.title")}</h2>
<p className="text-cream/60 text-sm mt-1">{t("trump.desc")}</p>
<div className="grid grid-cols-2 gap-3 mt-6">
{SUITS.map((suit) => {
const red = SUIT_IS_RED[suit];
return (
<motion.button
key={suit}
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.96 }}
onClick={() => choose(suit)}
className="rounded-2xl bg-navy-900/80 gold-border py-6 flex items-center justify-center hover:bg-navy-800 transition"
>
<span
className={cn(
"text-5xl",
red ? "text-rose-400" : "text-cream"
)}
>
{SUIT_SYMBOL[suit]}
</span>
</motion.button>
);
})}
</div>
</motion.div>
</Backdrop>
);
}
function RoundOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const r = game.lastRoundResult;
if (!r) return null;
const weWon = r.winningTeam === 0;
return (
<Backdrop>
<motion.div
initial={{ scale: 0.85, y: 20 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
>
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
{r.kot && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.15 }}
className="mt-3 inline-block rounded-full btn-gold px-5 py-1.5 text-lg font-black"
>
{t("round.kot")}🔥
</motion.div>
)}
<p
className={cn(
"mt-4 text-xl font-bold",
weWon ? "text-teal-300" : "text-rose-300"
)}
>
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
</p>
<p className="text-cream/70 mt-2">
{t("round.score", {
us: game.matchScore[0],
them: game.matchScore[1],
})}
</p>
<p className="text-cream/40 text-sm mt-5 animate-pulse">
{t("round.next")}
</p>
</motion.div>
</Backdrop>
);
}
function MatchOverlay({ onExit }: { onExit: () => void }) {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const youWin = game.matchWinner === 0;
return (
<Backdrop>
<motion.div
initial={{ scale: 0.85 }}
animate={{ scale: 1 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
>
<motion.div
initial={{ rotate: -15, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 160 }}
className="text-6xl mb-3"
>
{youWin ? "🏆" : "🎴"}
</motion.div>
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
<p
className={cn(
"mt-3 text-2xl font-bold",
youWin ? "text-gold-300" : "text-rose-300"
)}
>
{youWin ? t("match.youWin") : t("match.youLose")}
</p>
<p className="text-cream/70 mt-2">
{t("round.score", {
us: game.matchScore[0],
them: game.matchScore[1],
})}
</p>
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="btn-gold flex-1 rounded-xl py-3">
{t("match.menu")}
</button>
</div>
</motion.div>
</Backdrop>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { motion } from "framer-motion";
import {
Bot,
Globe,
LogIn,
LogOut,
ShoppingBag,
Trophy,
User,
Users,
Wifi,
} from "lucide-react";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { SUIT_SYMBOL } from "@/lib/hokm/types";
import { TopBar } from "./online/TopBar";
export function HomeScreen() {
const { t, toggle } = useI18n();
const newMatch = useGameStore((s) => s.newMatch);
const goGame = useUIStore((s) => s.goGame);
const go = useUIStore((s) => s.go);
const profile = useSessionStore((s) => s.profile);
const isAuthed = useSessionStore((s) => s.isAuthed);
const signOut = useSessionStore((s) => s.signOut);
const playVsComputer = () => {
const you = profile?.displayName || t("seat.you");
newMatch({ names: [you, "آرش", "کیان", "نیلوفر"], targetScore: 7 });
goGame("home");
};
const playOnline = () => (isAuthed ? go("online") : go("auth"));
return (
<main className="persian-pattern relative min-h-dvh w-full overflow-y-auto">
<FloatingSuits />
<div className="relative z-10 mx-auto w-full max-w-md p-4 sm:p-6 flex flex-col min-h-dvh">
<div className="pt-1">
<TopBar />
</div>
{/* logo */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center text-center mt-6 mb-7"
>
<div className="size-16 rounded-2xl gold-border flex items-center justify-center bg-navy-900 mb-3 shadow-lg">
<span className="gold-text text-4xl font-black leading-none"></span>
</div>
<h1 className="gold-text text-5xl font-black tracking-tight">
{t("app.title")}
</h1>
<p className="text-cream/60 mt-1 text-sm">{t("app.subtitle")}</p>
</motion.div>
{/* primary actions */}
<div className="space-y-3">
<PrimaryCard
icon={<Wifi className="size-6" />}
title={t("menu.online")}
desc={t("menu.onlineDesc")}
onClick={playOnline}
primary
/>
<PrimaryCard
icon={<Bot className="size-6" />}
title={t("menu.vsComputer")}
desc={t("menu.vsComputerDesc")}
onClick={playVsComputer}
/>
</div>
{/* tiles */}
<div className="grid grid-cols-4 gap-2.5 mt-4">
<Tile icon={<User className="size-5" />} label={t("menu.profile")} onClick={() => go("profile")} />
<Tile icon={<Users className="size-5" />} label={t("menu.friends")} onClick={() => go(isAuthed ? "friends" : "auth")} />
<Tile icon={<Trophy className="size-5" />} label={t("menu.leaderboard")} onClick={() => go("leaderboard")} />
<Tile icon={<ShoppingBag className="size-5" />} label={t("menu.shop")} onClick={() => go("shop")} />
</div>
<div className="flex-1" />
{/* footer */}
<div className="flex items-center justify-between gap-2 pt-6 pb-2">
{isAuthed ? (
<button
onClick={signOut}
className="glass rounded-full px-4 py-2 text-sm flex items-center gap-2 hover:bg-navy-800/80 transition"
>
<LogOut className="size-4 text-rose-300" />
{t("menu.signOut")}
</button>
) : (
<button
onClick={() => go("auth")}
className="btn-gold rounded-full px-4 py-2 text-sm flex items-center gap-2"
>
<LogIn className="size-4" />
{t("menu.signIn")}
</button>
)}
<button
onClick={toggle}
className="glass rounded-full px-4 py-2 text-sm flex items-center gap-2 hover:bg-navy-800/80 transition"
>
<Globe className="size-4 text-gold-400" />
{t("home.lang")}
</button>
</div>
</div>
</main>
);
}
function PrimaryCard({
icon,
title,
desc,
onClick,
primary,
}: {
icon: React.ReactNode;
title: string;
desc: string;
onClick: () => void;
primary?: boolean;
}) {
return (
<motion.button
whileTap={{ scale: 0.98 }}
whileHover={{ y: -2 }}
onClick={onClick}
className={
"w-full rounded-2xl p-4 flex items-center gap-4 text-start transition " +
(primary
? "btn-gold"
: "glass hover:bg-navy-800/80")
}
>
<span
className={
"size-12 rounded-xl flex items-center justify-center shrink-0 " +
(primary ? "bg-black/15 text-[#2a1f04]" : "bg-navy-900 gold-border text-gold-400")
}
>
{icon}
</span>
<span>
<span className={"block text-lg font-black " + (primary ? "text-[#2a1f04]" : "text-cream")}>
{title}
</span>
<span className={"block text-xs " + (primary ? "text-[#2a1f04]/70" : "text-cream/55")}>
{desc}
</span>
</span>
</motion.button>
);
}
function Tile({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<motion.button
whileTap={{ scale: 0.95 }}
onClick={onClick}
className="glass rounded-2xl py-3 flex flex-col items-center gap-1.5 hover:bg-navy-800/80 transition"
>
<span className="text-gold-400">{icon}</span>
<span className="text-[11px] text-cream/80">{label}</span>
</motion.button>
);
}
function FloatingSuits() {
const suits = Object.values(SUIT_SYMBOL);
const items = Array.from({ length: 8 }, (_, i) => ({
s: suits[i % 4],
left: `${(i * 13 + 6) % 95}%`,
delay: i * 0.7,
dur: 9 + (i % 4) * 2,
size: 28 + (i % 3) * 18,
}));
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{items.map((it, i) => (
<span
key={i}
className="float-suit absolute text-gold-500/10 font-black"
style={{
left: it.left,
fontSize: it.size,
animationDuration: `${it.dur}s`,
animationDelay: `${it.delay}s`,
}}
>
{it.s}
</span>
))}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { cn } from "@/lib/cn";
import { Card, SUIT_IS_RED, SUIT_SYMBOL, rankLabel } from "@/lib/hokm/types";
const SIZES = {
sm: { w: 44, h: 62, rank: "text-base", pip: "text-lg", center: "text-2xl" },
md: { w: 60, h: 84, rank: "text-lg", pip: "text-xl", center: "text-3xl" },
lg: { w: 74, h: 104, rank: "text-xl", pip: "text-2xl", center: "text-4xl" },
} as const;
export type CardSize = keyof typeof SIZES;
interface Props {
card?: Card;
faceDown?: boolean;
size?: CardSize;
className?: string;
dimmed?: boolean;
}
export function PlayingCard({
card,
faceDown,
size = "md",
className,
dimmed,
}: Props) {
const s = SIZES[size];
if (faceDown || !card) {
return (
<div
className={cn("card-back rounded-lg shrink-0", className)}
style={{ width: s.w, height: s.h }}
aria-hidden
>
<div className="h-full w-full rounded-lg flex items-center justify-center">
<div className="text-gold-500/70 text-lg font-bold"></div>
</div>
</div>
);
}
const red = SUIT_IS_RED[card.suit];
const color = red ? "text-rose-600" : "text-slate-900";
const symbol = SUIT_SYMBOL[card.suit];
return (
<div
className={cn(
"card-face rounded-lg shrink-0 relative select-none transition-opacity",
dimmed && "opacity-45",
className
)}
style={{ width: s.w, height: s.h }}
>
<div className={cn("absolute top-1 left-1.5 leading-none font-bold", color, s.rank)}>
<div>{rankLabel(card.rank)}</div>
<div className={s.rank}>{symbol}</div>
</div>
<div
className={cn(
"absolute inset-0 flex items-center justify-center font-bold",
color,
s.center
)}
>
{symbol}
</div>
<div
className={cn(
"absolute bottom-1 right-1.5 leading-none font-bold rotate-180",
color,
s.rank
)}
>
<div>{rankLabel(card.rank)}</div>
<div className={s.rank}>{symbol}</div>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Coins } from "lucide-react";
import { useEffect, useState } from "react";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { DAILY_REWARDS } from "@/lib/online/gamification";
import { getService } from "@/lib/online/service";
import { DailyRewardState } from "@/lib/online/types";
import { cn } from "@/lib/cn";
export function DailyRewardModal() {
const open = useUIStore((s) => s.dailyModalOpen);
const close = useUIStore((s) => s.closeDaily);
const refreshProfile = useSessionStore((s) => s.refreshProfile);
const { t } = useI18n();
const [state, setState] = useState<DailyRewardState | null>(null);
const [claimed, setClaimed] = useState<number | null>(null);
useEffect(() => {
if (open) {
setClaimed(null);
getService().getDailyState().then(setState);
}
}, [open]);
const claim = async () => {
const res = await getService().claimDaily();
setClaimed(res.reward);
await refreshProfile();
setState(await getService().getDailyState());
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={close}
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
>
<motion.div
initial={{ scale: 0.9, y: 16 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass rounded-3xl p-7 w-full max-w-sm text-center"
>
<h2 className="gold-text text-2xl font-black">{t("daily.title")}</h2>
<div className="grid grid-cols-4 gap-2 mt-5">
{DAILY_REWARDS.map((coins, i) => {
const day = i + 1;
const isToday = state?.day === day && state?.available;
const isPast = state ? day < state.day : false;
return (
<div
key={day}
className={cn(
"rounded-xl py-2.5 flex flex-col items-center gap-1 border",
i === 6 && "col-span-4 flex-row justify-center gap-3",
isToday
? "btn-gold border-transparent"
: isPast
? "bg-navy-900/50 border-teal-500/30 opacity-60"
: "bg-navy-900/70 gold-border"
)}
>
<span className={cn("text-[10px]", isToday ? "text-[#2a1f04]" : "text-cream/60")}>
{t("daily.day", { n: day })}
</span>
<span
className={cn(
"flex items-center gap-1 text-sm font-bold",
isToday ? "text-[#2a1f04]" : "text-gold-300"
)}
>
{coins}
<Coins className="size-3" />
</span>
</div>
);
})}
</div>
{claimed != null ? (
<p className="mt-5 text-teal-300 font-bold flex items-center justify-center gap-1.5">
+{claimed} <Coins className="size-4 text-gold-400" /> {t("daily.claimed")}
</p>
) : state?.available ? (
<button onClick={claim} className="btn-gold w-full rounded-xl py-3 mt-5">
{t("daily.claim")}
</button>
) : (
<p className="mt-5 text-cream/50 text-sm">{t("daily.come")}</p>
)}
<button
onClick={close}
className="mt-3 text-cream/50 text-sm hover:text-cream/80"
>
{t("common.back")}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,165 @@
"use client";
import { motion } from "framer-motion";
import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
import { useI18n } from "@/lib/i18n";
import { RewardResult } from "@/lib/online/types";
export function PostMatchRewardsModal({
reward,
won,
onClose,
}: {
reward: RewardResult;
won: boolean;
onClose: () => void;
}) {
const { t, locale } = useI18n();
const sign = (n: number) => (n > 0 ? `+${n}` : `${n}`);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
>
<motion.div
initial={{ scale: 0.85, y: 24 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
className="glass rounded-3xl p-7 w-full max-w-sm text-center"
>
<motion.div
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 160, delay: 0.1 }}
className="text-5xl mb-2"
>
{won ? "🏆" : "🎴"}
</motion.div>
<h2 className="gold-text text-2xl font-black">{t("reward.title")}</h2>
<p className={"mt-1 font-bold " + (won ? "text-teal-300" : "text-rose-300")}>
{won ? t("reward.win") : t("reward.lose")}
</p>
<div className="mt-5 space-y-2.5">
{reward.ratingDelta !== 0 && (
<RewardRow
icon={
reward.ratingDelta > 0 ? (
<TrendingUp className="size-4 text-teal-300" />
) : (
<TrendingDown className="size-4 text-rose-300" />
)
}
label={t("reward.rating")}
value={sign(reward.ratingDelta)}
positive={reward.ratingDelta > 0}
delay={0.2}
/>
)}
<RewardRow
icon={<Coins className="size-4 text-gold-400" />}
label={t("reward.coins")}
value={sign(reward.coinsDelta)}
positive={reward.coinsDelta >= 0}
delay={0.3}
/>
<RewardRow
icon={<Star className="size-4 text-gold-400" />}
label={t("reward.xp")}
value={`+${reward.xpGained}`}
positive
delay={0.4}
/>
</div>
{reward.leveledUp && (
<Banner delay={0.5} text={`${t("reward.levelUp")}${reward.levelAfter}`} />
)}
{reward.promoted && <Banner delay={0.55} text={t("reward.promoted")} />}
{reward.newAchievements.length > 0 && (
<div className="mt-4 space-y-2">
{reward.newAchievements.map((a, i) => (
<motion.div
key={a.id}
initial={{ opacity: 0, x: locale === "fa" ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 + i * 0.12 }}
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
>
<span className="text-xl">{a.icon}</span>
<span className="flex-1">
<span className="block text-[10px] text-gold-400">
{t("reward.newAchievement")}
</span>
<span className="block text-sm text-cream font-semibold">
{locale === "fa" ? a.nameFa : a.nameEn}
</span>
</span>
<span className="text-xs text-gold-300 flex items-center gap-1">
+{a.coinReward}
<Coins className="size-3" />
</span>
</motion.div>
))}
</div>
)}
<button onClick={onClose} className="btn-gold w-full rounded-xl py-3 mt-6">
{t("reward.continue")}
</button>
</motion.div>
</motion.div>
);
}
function RewardRow({
icon,
label,
value,
positive,
delay,
}: {
icon: React.ReactNode;
label: string;
value: string;
positive: boolean;
delay: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
className="glass rounded-xl px-4 py-2.5 flex items-center justify-between"
>
<span className="flex items-center gap-2 text-cream/80 text-sm">
{icon}
{label}
</span>
<span
className={
"font-black tabular-nums " + (positive ? "text-teal-300" : "text-rose-300")
}
>
{value}
</span>
</motion.div>
);
}
function Banner({ text, delay }: { text: string; delay: number }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay }}
className="mt-4 inline-flex items-center gap-2 btn-gold rounded-full px-5 py-1.5 font-black"
>
<Sparkles className="size-4" />
{text}
</motion.div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Shield } from "lucide-react";
import { divisionLabel, getLeagueInfo } from "@/lib/online/gamification";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
export function RankBadge({
rating,
className,
showRating,
}: {
rating: number;
className?: string;
showRating?: boolean;
}) {
const { locale } = useI18n();
const l = getLeagueInfo(rating);
const name = locale === "fa" ? l.tier.nameFa : l.tier.nameEn;
const div = divisionLabel(l.division);
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold",
className
)}
style={{
color: l.tier.color,
background: `${l.tier.color}1a`,
border: `1px solid ${l.tier.color}55`,
}}
>
<Shield className="size-3.5" style={{ fill: `${l.tier.color}33` }} />
{name}
{div && <span className="opacity-80">{div}</span>}
{showRating && <span className="opacity-70">· {Math.round(rating)}</span>}
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useI18n } from "@/lib/i18n";
import { useUIStore, type Screen } from "@/lib/ui-store";
export function ScreenHeader({
title,
back = "home",
right,
}: {
title: string;
back?: Screen;
right?: React.ReactNode;
}) {
const go = useUIStore((s) => s.go);
const { locale } = useI18n();
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
return (
<div className="flex items-center justify-between gap-3 mb-5">
<button
onClick={() => go(back)}
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
>
<Chevron className="size-5 text-cream/80" />
</button>
<h1 className="gold-text text-2xl font-black">{title}</h1>
<div className="min-w-10 flex justify-end">{right}</div>
</div>
);
}
export function ScreenShell({ children }: { children: React.ReactNode }) {
return (
<main className="persian-pattern relative min-h-dvh w-full overflow-y-auto">
<div className="mx-auto w-full max-w-2xl p-4 sm:p-6">{children}</div>
</main>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { Coins, 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";
export function TopBar() {
const profile = useSessionStore((s) => s.profile);
const go = useUIStore((s) => s.go);
const openDaily = useUIStore((s) => s.openDaily);
const { t } = useI18n();
if (!profile) return null;
return (
<div className="flex items-center justify-between gap-3">
<button
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"
>
<span className="size-9 rounded-full bg-navy-900 gold-border flex items-center justify-center text-xl">
{avatarEmoji(profile.avatar)}
</span>
<span className="text-start leading-tight">
<span className="block text-sm font-bold text-cream max-w-24 truncate">
{profile.displayName}
</span>
<span className="block text-[10px] text-gold-400/80">
{t("common.level")} {profile.level}
</span>
</span>
</button>
<div className="flex items-center gap-2">
<button
onClick={openDaily}
className="glass rounded-full p-2 hover:bg-navy-800/80 transition"
title={t("daily.title")}
>
<Gift className="size-4 text-gold-400" />
</button>
<div className="glass rounded-full px-3 py-1.5 flex items-center gap-1.5">
<Coins className="size-4 text-gold-400" />
<span className="text-sm font-bold text-cream tabular-nums">
{profile.coins.toLocaleString()}
</span>
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { xpNeededForLevel } from "@/lib/online/gamification";
import { useI18n } from "@/lib/i18n";
export function XpBar({ level, xp }: { level: number; xp: number }) {
const { t } = useI18n();
const need = xpNeededForLevel(level);
const pct = Math.min(100, Math.round((xp / need) * 100));
return (
<div className="w-full">
<div className="flex items-center justify-between text-[10px] text-cream/55 mb-1">
<span>
{t("common.level")} {level}
</span>
<span className="tabular-nums">
{xp} / {need} XP
</span>
</div>
<div className="h-2.5 rounded-full bg-navy-900/80 overflow-hidden gold-border">
<div
className="h-full rounded-full transition-all"
style={{
width: `${pct}%`,
background: "linear-gradient(90deg, var(--gold-500), var(--gold-300))",
}}
/>
</div>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { motion } from "framer-motion";
import { Mail, Phone } from "lucide-react";
import { useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
type Tab = "phone" | "email";
export function AuthScreen() {
const { t } = useI18n();
const go = useUIStore((s) => s.go);
const s = useSessionStore();
const [tab, setTab] = useState<Tab>("phone");
const done = () => go("online");
return (
<ScreenShell>
<ScreenHeader title={t("auth.title")} />
<div className="glass rounded-3xl p-6 max-w-md mx-auto">
<p className="text-center text-cream/60 text-sm mb-5">{t("auth.subtitle")}</p>
<div className="flex gap-2 p-1 rounded-xl bg-navy-900/70 mb-5">
<TabBtn active={tab === "phone"} onClick={() => setTab("phone")} icon={<Phone className="size-4" />} label={t("auth.phone")} />
<TabBtn active={tab === "email"} onClick={() => setTab("email")} icon={<Mail className="size-4" />} label={t("auth.email")} />
</div>
{tab === "phone" ? <PhoneForm onDone={done} /> : <EmailForm onDone={done} />}
<div className="mt-5 pt-5 border-t border-gold-500/15">
<button
onClick={async () => {
await s.signInGoogle();
done();
}}
className="w-full rounded-xl bg-white text-slate-800 font-bold py-3 flex items-center justify-center gap-2 hover:bg-white/90 transition"
>
<GoogleIcon />
{t("auth.google")}
</button>
</div>
</div>
</ScreenShell>
);
}
function TabBtn({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex-1 rounded-lg py-2 text-sm font-bold flex items-center justify-center gap-1.5 transition",
active ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
{icon}
{label}
</button>
);
}
function PhoneForm({ onDone }: { onDone: () => void }) {
const { t } = useI18n();
const requestOtp = useSessionStore((s) => s.requestOtp);
const verifyOtp = useSessionStore((s) => s.verifyOtp);
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState("");
const send = async () => {
if (phone.trim().length < 4) return;
const res = await requestOtp(phone.trim());
setDevCode(res.devCode ?? null);
setError("");
};
const verify = async () => {
try {
await verifyOtp(phone.trim(), code.trim());
onDone();
} catch {
setError(t("auth.invalidCode"));
}
};
return (
<div className="space-y-3">
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.phoneLabel")}</label>
<input
dir="ltr"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder={t("auth.phonePlaceholder")}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream text-center tracking-wider outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
{devCode == null ? (
<button onClick={send} className="btn-gold w-full rounded-xl py-3">
{t("auth.sendCode")}
</button>
) : (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-3">
<div className="text-center text-xs text-gold-300 glass rounded-lg py-1.5">
{t("auth.devCode", { code: devCode })}
</div>
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.codeLabel")}</label>
<input
dir="ltr"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("auth.codePlaceholder")}
maxLength={4}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream text-center text-xl tracking-[0.5em] outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
{error && <p className="text-rose-300 text-sm text-center">{error}</p>}
<button onClick={verify} className="btn-gold w-full rounded-xl py-3">
{t("auth.verify")}
</button>
</motion.div>
)}
</div>
);
}
function EmailForm({ onDone }: { onDone: () => void }) {
const { t } = useI18n();
const signInEmail = useSessionStore((s) => s.signInEmail);
const signUpEmail = useSessionStore((s) => s.signUpEmail);
const [mode, setMode] = useState<"in" | "up">("in");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const submit = async () => {
if (!email.trim() || !password.trim()) return;
if (mode === "in") await signInEmail(email.trim(), password);
else await signUpEmail(email.trim(), password, name.trim());
onDone();
};
return (
<div className="space-y-3">
{mode === "up" && (
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.nameLabel")}</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
)}
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.emailLabel")}</label>
<input
dir="ltr"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.passLabel")}</label>
<input
dir="ltr"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
<button onClick={submit} className="btn-gold w-full rounded-xl py-3">
{mode === "in" ? t("auth.signIn") : t("auth.signUp")}
</button>
<button
onClick={() => setMode(mode === "in" ? "up" : "in")}
className="w-full text-center text-sm text-cream/55 hover:text-cream"
>
{mode === "in" ? t("auth.toggleSignup") : t("auth.toggleSignin")}
</button>
</div>
);
}
function GoogleIcon() {
return (
<svg className="size-4" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23z" />
<path fill="#FBBC05" d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z" />
</svg>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { Check, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useOnlineStore } from "@/lib/online-store";
import { useI18n } from "@/lib/i18n";
import { Friend, PresenceStatus, avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
online: "bg-teal-400",
offline: "bg-slate-500",
"in-game": "bg-gold-400",
};
export function FriendsScreen() {
const { t, locale } = useI18n();
const friends = useOnlineStore((s) => s.friends);
const requests = useOnlineStore((s) => s.requests);
const load = useOnlineStore((s) => s.loadFriends);
const addFriend = useOnlineStore((s) => s.addFriend);
const accept = useOnlineStore((s) => s.acceptRequest);
const decline = useOnlineStore((s) => s.declineRequest);
const remove = useOnlineStore((s) => s.removeFriend);
const [query, setQuery] = useState("");
useEffect(() => {
load();
}, [load]);
const statusLabel = (s: PresenceStatus) =>
s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline");
const add = async () => {
if (!query.trim()) return;
await addFriend(query);
setQuery("");
};
return (
<ScreenShell>
<ScreenHeader title={t("friends.title")} />
{/* add */}
<div className="glass rounded-2xl p-3 flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder={t("friends.addPlaceholder")}
className="flex-1 rounded-xl bg-navy-900/70 gold-border px-3 py-2 text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40"
/>
<button onClick={add} className="btn-gold rounded-xl px-4 flex items-center gap-1.5">
<UserPlus className="size-4" />
{t("friends.add")}
</button>
</div>
{/* requests */}
{requests.length > 0 && (
<div className="mt-4">
<h3 className="text-xs text-cream/55 mb-2">{t("friends.requests")}</h3>
<div className="space-y-2">
{requests.map((r) => (
<div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<span className="text-2xl">{avatarEmoji(r.from.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">
{r.from.displayName}
</span>
<button
onClick={() => accept(r.id)}
className="size-8 rounded-lg bg-teal-600/80 flex items-center justify-center hover:bg-teal-600"
>
<Check className="size-4 text-white" />
</button>
<button
onClick={() => decline(r.id)}
className="size-8 rounded-lg bg-rose-700/70 flex items-center justify-center hover:bg-rose-700"
>
<X className="size-4 text-white" />
</button>
</div>
))}
</div>
</div>
)}
{/* list */}
<div className="mt-4 space-y-2 pb-6">
{friends.length === 0 && (
<p className="text-center text-cream/40 py-10">{t("friends.empty")}</p>
)}
{friends.map((f: Friend) => (
<div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<div className="relative">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span
className={cn(
"absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900",
STATUS_COLOR[f.status]
)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate">{f.displayName}</div>
<div className="text-[11px] text-cream/45">
{statusLabel(f.status)} · {t("common.level")} {f.level}
</div>
</div>
<span className="text-[11px] text-gold-300/80">{Math.round(f.rating)}</span>
<button
onClick={() => remove(f.id)}
className="size-8 rounded-lg hover:bg-rose-700/40 flex items-center justify-center text-cream/40 hover:text-rose-300"
title={t("friends.remove")}
>
<X className="size-4" />
</button>
</div>
))}
</div>
<span className="sr-only">{locale}</span>
</ScreenShell>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GameTable } from "@/components/GameTable";
import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { getService } from "@/lib/online/service";
import { MatchSummary, RewardResult } from "@/lib/online/types";
export function GameScreen() {
const game = useGameStore((s) => s.game);
const mode = useGameStore((s) => s.mode);
const tally = useGameStore((s) => s.tally);
const meta = useGameStore((s) => s.matchMeta);
const reset = useGameStore((s) => s.reset);
const returnTo = useUIStore((s) => s.returnTo);
const go = useUIStore((s) => s.go);
const refreshProfile = useSessionStore((s) => s.refreshProfile);
const [reward, setReward] = useState<RewardResult | null>(null);
const submitted = useRef(false);
const exit = () => {
reset();
go(returnTo);
};
useEffect(() => {
if (mode === "online" && game.phase === "match-over" && !submitted.current) {
submitted.current = true;
const summary: MatchSummary = {
ranked: meta.ranked,
stake: meta.stake,
won: game.matchWinner === 0,
kotFor: tally.kotFor,
kotAgainst: tally.kotAgainst,
tricksWon: tally.tricksTeam0,
rounds: game.matchScore[0] + game.matchScore[1],
trump: game.trump,
};
getService()
.submitMatchResult(summary)
.then((r) => {
setReward(r);
refreshProfile();
});
}
}, [mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
return (
<>
<GameTable onExit={exit} />
{reward && (
<PostMatchRewardsModal
reward={reward}
won={game.matchWinner === 0}
onClose={() => {
setReward(null);
exit();
}}
/>
)}
</>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useEffect } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { RankBadge } from "@/components/online/RankBadge";
import { useOnlineStore } from "@/lib/online-store";
import { useI18n } from "@/lib/i18n";
import { avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
const MEDALS: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
export function LeaderboardScreen() {
const { t } = useI18n();
const leaderboard = useOnlineStore((s) => s.leaderboard);
const load = useOnlineStore((s) => s.loadLeaderboard);
useEffect(() => {
load();
}, [load]);
return (
<ScreenShell>
<ScreenHeader title={t("lead.title")} />
<div className="space-y-1.5 pb-6">
{leaderboard.map((e) => (
<div
key={e.id}
className={cn(
"rounded-xl p-2.5 flex items-center gap-3 border",
e.isYou
? "bg-gold-500/15 border-gold-500/50"
: "glass border-transparent"
)}
>
<span className="w-7 text-center font-black text-cream/70 tabular-nums">
{MEDALS[e.rank] ?? e.rank}
</span>
<span className="text-2xl">{avatarEmoji(e.avatar)}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate">
{e.displayName}
{e.isYou && <span className="text-gold-300"> ({t("seat.you")})</span>}
</div>
<div className="text-[10px] text-cream/45">
{t("common.level")} {e.level}
</div>
</div>
<RankBadge rating={e.rating} showRating />
</div>
))}
</div>
</ScreenShell>
);
}
@@ -0,0 +1,122 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2 } from "lucide-react";
import { ScreenShell } from "@/components/online/ScreenHeader";
import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { avatarEmoji } from "@/lib/online/types";
export function MatchmakingScreen() {
const { t } = useI18n();
const mm = useOnlineStore((s) => s.matchmaking);
const cancelMatchmaking = useOnlineStore((s) => s.cancelMatchmaking);
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
const goGame = useUIStore((s) => s.goGame);
const go = useUIStore((s) => s.go);
const ready = mm.phase === "ready";
const slots = [0, 1, 2, 3];
const cancel = async () => {
await cancelMatchmaking();
go("online");
};
const enter = () => {
const players = getService().getMatchPlayers();
if (!players) return;
newOnlineMatch({
players: players.map((p) => ({
displayName: p.displayName,
avatar: p.avatar,
level: p.level,
})),
targetScore: 7,
stake: mm.stake,
ranked: mm.ranked,
});
goGame("home");
};
return (
<ScreenShell>
<div className="flex flex-col items-center justify-center min-h-[80dvh] text-center">
<motion.div
animate={ready ? {} : { rotate: 360 }}
transition={{ repeat: ready ? 0 : Infinity, duration: 2, ease: "linear" }}
className="mb-6"
>
{ready ? (
<span className="text-5xl"></span>
) : (
<Loader2 className="size-12 text-gold-400" />
)}
</motion.div>
<h1 className="gold-text text-2xl font-black">
{ready ? t("mm.ready") : mm.phase === "found" ? t("mm.found") : t("mm.searching")}
</h1>
<div className="grid grid-cols-4 gap-3 mt-8">
{slots.map((i) => {
const p = mm.players[i];
return (
<div
key={i}
className="w-16 h-20 rounded-2xl glass flex flex-col items-center justify-center gap-1"
>
<AnimatePresence mode="wait">
{p ? (
<motion.div
key={p.id}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center gap-0.5"
>
<span className="text-2xl">{avatarEmoji(p.avatar)}</span>
<span className="text-[9px] text-cream/70 max-w-14 truncate">
{p.displayName}
</span>
<span className="text-[8px] text-gold-400/70">
{t("common.level")} {p.level}
</span>
</motion.div>
) : (
<motion.span
key="empty"
className="text-cream/20 text-2xl"
animate={{ opacity: [0.2, 0.5, 0.2] }}
transition={{ repeat: Infinity, duration: 1.4 }}
>
?
</motion.span>
)}
</AnimatePresence>
</div>
);
})}
</div>
<div className="mt-10 flex gap-3">
<button onClick={cancel} className="glass rounded-xl px-6 py-3 text-cream/70 hover:text-cream">
{t("mm.cancel")}
</button>
{ready && (
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={enter}
className="btn-gold rounded-xl px-8 py-3 text-lg"
>
{t("mm.start")}
</motion.button>
)}
</div>
</div>
</ScreenShell>
);
}
@@ -0,0 +1,89 @@
"use client";
import { motion } from "framer-motion";
import { Coins, Trophy, Users } from "lucide-react";
import { useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
const STAKES = [0, 100, 500, 1000];
export function OnlineLobbyScreen() {
const { t } = useI18n();
const createRoom = useOnlineStore((s) => s.createRoom);
const startMatchmaking = useOnlineStore((s) => s.startMatchmaking);
const go = useUIStore((s) => s.go);
const [stake, setStake] = useState(100);
const onCreate = async () => {
await createRoom({ targetScore: 7, stake, ranked: false });
go("room");
};
const onRandom = async () => {
await startMatchmaking({ ranked: true, stake });
go("matchmaking");
};
return (
<ScreenShell>
<ScreenHeader title={t("lobby.title")} />
{/* stake */}
<div className="glass rounded-2xl p-4 mb-4">
<div className="flex items-center gap-1.5 text-sm text-cream/70 mb-2.5">
<Coins className="size-4 text-gold-400" />
{t("room.stake")}
</div>
<div className="flex gap-2">
{STAKES.map((s) => (
<button
key={s}
onClick={() => setStake(s)}
className={cn(
"flex-1 rounded-xl py-2.5 text-sm font-bold transition",
stake === s ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
)}
>
{s === 0 ? t("menu.guest") : s.toLocaleString()}
</button>
))}
</div>
</div>
<div className="space-y-3">
<motion.button
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
onClick={onRandom}
className="btn-gold w-full rounded-2xl p-5 flex items-center gap-4 text-start"
>
<span className="size-12 rounded-xl bg-black/15 flex items-center justify-center text-[#2a1f04]">
<Trophy className="size-6" />
</span>
<span>
<span className="block text-lg font-black text-[#2a1f04]">{t("lobby.random")}</span>
<span className="block text-xs text-[#2a1f04]/70">{t("lobby.randomDesc")}</span>
</span>
</motion.button>
<motion.button
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
onClick={onCreate}
className="glass w-full rounded-2xl p-5 flex items-center gap-4 text-start hover:bg-navy-800/80 transition"
>
<span className="size-12 rounded-xl bg-navy-900 gold-border flex items-center justify-center text-gold-400">
<Users className="size-6" />
</span>
<span>
<span className="block text-lg font-black text-cream">{t("lobby.createRoom")}</span>
<span className="block text-xs text-cream/55">{t("lobby.createDesc")}</span>
</span>
</motion.button>
</div>
</ScreenShell>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { Check, Coins, Pencil } from "lucide-react";
import { useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { RankBadge } from "@/components/online/RankBadge";
import { XpBar } from "@/components/online/XpBar";
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 { cn } from "@/lib/cn";
export function ProfileScreen() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const updateProfile = useSessionStore((s) => s.updateProfile);
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 saveName = async () => {
if (name.trim()) await updateProfile({ displayName: name.trim() });
setEditing(false);
};
return (
<ScreenShell>
<ScreenHeader title={t("profile.title")} />
{/* identity */}
<div className="glass rounded-3xl p-5 text-center">
<div className="size-20 mx-auto rounded-2xl bg-navy-900 gold-border flex items-center justify-center text-4xl">
{avatarEmoji(profile.avatar)}
</div>
{editing ? (
<div className="mt-3 flex items-center justify-center gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded-lg bg-navy-900/70 gold-border px-3 py-1.5 text-center text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
<button onClick={saveName} className="btn-gold rounded-lg p-2">
<Check className="size-4" />
</button>
</div>
) : (
<button
onClick={() => {
setName(profile.displayName);
setEditing(true);
}}
className="mt-3 inline-flex items-center gap-2 text-xl font-black text-cream hover:text-gold-300 transition"
>
{profile.displayName}
<Pencil className="size-3.5 text-cream/40" />
</button>
)}
<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>
</div>
<div className="mt-4">
<XpBar level={profile.level} xp={profile.xp} />
</div>
</div>
{/* avatar picker */}
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.chooseAvatar")}</h3>
<div className="flex flex-wrap gap-2">
{AVATARS.filter((a) => profile.ownedAvatars.includes(a.id)).map((a) => (
<button
key={a.id}
onClick={() => updateProfile({ avatar: a.id })}
className={cn(
"size-12 rounded-xl bg-navy-900/70 flex items-center justify-center text-2xl transition",
profile.avatar === a.id ? "gold-border ring-2 ring-gold-400/60" : "border border-transparent hover:bg-navy-800"
)}
>
{a.emoji}
</button>
))}
</div>
</div>
{/* stats */}
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.stats")}</h3>
<div className="grid grid-cols-3 gap-2.5">
<Stat label={t("profile.games")} value={s.games} />
<Stat label={t("profile.wins")} value={s.wins} />
<Stat label={t("profile.winrate")} value={`${winrate}%`} />
<Stat label={t("profile.kots")} value={s.kotsFor} />
<Stat label={t("profile.streak")} value={s.bestWinStreak} />
<Stat label={t("common.rating")} value={Math.round(profile.rating)} />
</div>
</div>
{/* achievements */}
<div className="glass rounded-2xl p-4 mt-4 mb-6">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.achievements")}</h3>
<div className="space-y-2">
{ACHIEVEMENTS.map((a) => {
const prog = achievementProgress(a.id, s, profile.rating);
const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal;
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
return (
<div
key={a.id}
className={cn(
"rounded-xl p-3 flex items-center gap-3 border",
unlocked ? "bg-gold-500/10 border-gold-500/40" : "bg-navy-900/50 border-navy-700/50"
)}
>
<span className={cn("text-2xl", !unlocked && "grayscale opacity-50")}>
{a.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream">
{locale === "fa" ? a.nameFa : a.nameEn}
</div>
<div className="text-[11px] text-cream/50 truncate">
{locale === "fa" ? a.descFa : a.descEn}
</div>
{!unlocked && a.goal > 1 && (
<div className="h-1.5 rounded-full bg-navy-900 overflow-hidden mt-1.5">
<div
className="h-full bg-gold-500/70"
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
{unlocked && <Check className="size-4 text-gold-400 shrink-0" />}
</div>
);
})}
</div>
</div>
</ScreenShell>
);
}
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-navy-900/60 rounded-xl py-3 text-center">
<div className="text-xl font-black gold-text">{value}</div>
<div className="text-[10px] text-cream/55 mt-0.5">{label}</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Bot, Copy, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { Friend, RoomSeat, avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
export function RoomScreen() {
const { t } = useI18n();
const room = useOnlineStore((s) => s.room);
const friends = useOnlineStore((s) => s.friends);
const loadFriends = useOnlineStore((s) => s.loadFriends);
const setPartner = useOnlineStore((s) => s.setPartner);
const inviteToSeat = useOnlineStore((s) => s.inviteToSeat);
const addBot = useOnlineStore((s) => s.addBot);
const clearSeat = useOnlineStore((s) => s.clearSeat);
const startRoom = useOnlineStore((s) => s.startRoom);
const leaveRoom = useOnlineStore((s) => s.leaveRoom);
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
const goGame = useUIStore((s) => s.goGame);
const go = useUIStore((s) => s.go);
const [picker, setPicker] = useState<null | { seat: 1 | 2 | 3 }>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
loadFriends();
}, [loadFriends]);
if (!room) return null;
const seat = (n: number) => room.seats.find((s) => s.seat === n)!;
const pick = async (friend: Friend) => {
if (!picker) return;
if (picker.seat === 2) await setPartner(friend.id);
else await inviteToSeat(picker.seat, friend.id);
setPicker(null);
};
const copyCode = async () => {
try {
await navigator.clipboard.writeText(room.code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const start = async () => {
await startRoom();
const r = useOnlineStore.getState().room!;
const players = r.seats
.slice()
.sort((a, b) => a.seat - b.seat)
.map((s) => ({
displayName: s.player!.displayName,
avatar: s.player!.avatar,
level: s.player!.level,
}));
newOnlineMatch({ players, targetScore: r.targetScore, stake: r.stake, ranked: r.ranked });
goGame("home");
};
const leave = async () => {
await leaveRoom();
go("online");
};
return (
<ScreenShell>
<ScreenHeader
title={t("room.title")}
back="online"
right={
<button
onClick={copyCode}
className="glass rounded-full px-3 py-1.5 text-xs flex items-center gap-1.5 hover:bg-navy-800/80"
>
<Copy className="size-3.5 text-gold-400" />
<span className="tabular-nums tracking-wider">{copied ? t("common.copied") : room.code}</span>
</button>
}
/>
{/* your team */}
<h3 className="text-xs text-teal-300 font-bold mb-2">{t("team.us")}</h3>
<div className="grid grid-cols-2 gap-3">
<SeatCard seat={seat(0)} role="you" onInvite={() => {}} onBot={() => {}} onClear={() => {}} />
<SeatCard
seat={seat(2)}
role="partner"
onInvite={() => setPicker({ seat: 2 })}
onBot={() => addBot(2)}
onClear={() => clearSeat(2)}
/>
</div>
{/* opponents */}
<h3 className="text-xs text-rose-300 font-bold mt-5 mb-2">{t("room.opponents")}</h3>
<div className="grid grid-cols-2 gap-3">
<SeatCard
seat={seat(1)}
role="opp"
onInvite={() => setPicker({ seat: 1 })}
onBot={() => addBot(1)}
onClear={() => clearSeat(1)}
/>
<SeatCard
seat={seat(3)}
role="opp"
onInvite={() => setPicker({ seat: 3 })}
onBot={() => addBot(3)}
onClear={() => clearSeat(3)}
/>
</div>
<div className="flex gap-3 mt-7">
<button onClick={leave} className="glass rounded-xl px-5 py-3 text-cream/70 hover:text-cream">
{t("room.leave")}
</button>
<button onClick={start} className="btn-gold flex-1 rounded-xl py-3 text-lg">
{t("room.start")}
</button>
</div>
{/* friend picker */}
<AnimatePresence>
{picker && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setPicker(null)}
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-navy-950/80 backdrop-blur-sm p-4"
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto"
>
<h3 className="text-lg font-black gold-text mb-3">{t("room.pickFriend")}</h3>
<div className="space-y-2">
{friends.map((f) => (
<button
key={f.id}
onClick={() => pick(f)}
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
>
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">{f.displayName}</span>
<span className="text-[11px] text-cream/45">
{t("common.level")} {f.level}
</span>
</button>
))}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</ScreenShell>
);
}
function SeatCard({
seat,
role,
onInvite,
onBot,
onClear,
}: {
seat: RoomSeat;
role: "you" | "partner" | "opp";
onInvite: () => void;
onBot: () => void;
onClear: () => void;
}) {
const { t } = useI18n();
const filled = seat.kind !== "empty";
const label =
role === "you" ? t("seat.you") : role === "partner" ? t("room.partner") : t("room.opponents");
return (
<div
className={cn(
"rounded-2xl p-4 min-h-32 flex flex-col items-center justify-center gap-2 border",
role === "opp" ? "border-rose-500/25 bg-rose-950/20" : "border-teal-500/25 bg-teal-950/20"
)}
>
<span className="text-[10px] text-cream/50">{label}</span>
{filled ? (
<>
<span className="text-3xl">{avatarEmoji(seat.player?.avatar ?? "a-fox")}</span>
<span className="text-sm font-bold text-cream text-center max-w-full truncate">
{seat.player?.displayName}
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
</span>
{seat.kind === "invited" ? (
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
) : (
role !== "you" && (
<button onClick={onClear} className="text-[10px] text-rose-300/70 hover:text-rose-300 flex items-center gap-1">
<X className="size-3" />
</button>
)
)}
</>
) : (
<div className="flex flex-col gap-1.5 w-full">
<button
onClick={onInvite}
className="rounded-lg bg-navy-900/70 gold-border py-1.5 text-xs text-cream/80 hover:bg-navy-800 flex items-center justify-center gap-1.5"
>
<UserPlus className="size-3.5" />
{t("room.invite")}
</button>
<button
onClick={onBot}
className="rounded-lg bg-navy-900/70 gold-border py-1.5 text-xs text-cream/80 hover:bg-navy-800 flex items-center justify-center gap-1.5"
>
<Bot className="size-3.5" />
{t("room.addBot")}
</button>
</div>
)}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { Check, Coins } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { ShopItem } from "@/lib/online/types";
import { cn } from "@/lib/cn";
export function ShopScreen() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const setProfile = useSessionStore((s) => s.setProfile);
const [items, setItems] = useState<ShopItem[]>([]);
const [msg, setMsg] = useState("");
useEffect(() => {
getService().getShopItems().then(setItems);
}, []);
if (!profile) return null;
const owns = (item: ShopItem) =>
item.kind === "avatar"
? profile.ownedAvatars.includes(item.id)
: profile.ownedThemes.includes(item.id);
const buy = async (item: ShopItem) => {
const res = await getService().buyItem(item.id);
if (res.ok && res.profile) {
setProfile(res.profile);
} else {
setMsg(locale === "fa" ? res.messageFa : res.messageEn);
setTimeout(() => setMsg(""), 1800);
}
};
const avatars = items.filter((i) => i.kind === "avatar");
const themes = items.filter((i) => i.kind === "theme");
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>
}
/>
{msg && (
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)}
<Section title={t("shop.avatars")}>
<div className="grid grid-cols-3 gap-3">
{avatars.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} onBuy={() => buy(item)} preview={<span className="text-4xl">{item.preview}</span>} />
))}
</div>
</Section>
<Section title={t("shop.themes")}>
<div className="grid grid-cols-3 gap-3">
{themes.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={owns(item)}
onBuy={() => buy(item)}
preview={
<span
className="size-10 rounded-xl border border-white/20"
style={{ background: item.preview }}
/>
}
/>
))}
</div>
</Section>
</ScreenShell>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-5">
<h3 className="text-sm font-bold text-cream/80 mb-3">{title}</h3>
{children}
</div>
);
}
function ItemCard({
item,
owned,
onBuy,
preview,
}: {
item: ShopItem;
owned: boolean;
onBuy: () => void;
preview: React.ReactNode;
}) {
const { t } = useI18n();
return (
<div className="glass rounded-2xl p-3 flex flex-col items-center gap-2">
<div className="h-12 flex items-center justify-center">{preview}</div>
<button
disabled={owned}
onClick={onBuy}
className={cn(
"w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1",
owned ? "bg-navy-900/60 text-teal-300" : "btn-gold"
)}
>
{owned ? (
<>
<Check className="size-3.5" />
{t("shop.owned")}
</>
) : (
<>
<Coins className="size-3.5" />
{item.price.toLocaleString()}
</>
)}
</button>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { create } from "zustand";
import { chooseCardAI, chooseTrumpAI } from "./hokm/ai";
import {
advanceAfterTrick,
chooseTrump as engineChooseTrump,
createInitialState,
dealForTrump,
playCard,
selectHakem,
startNextRound,
} from "./hokm/engine";
import { Card, GameState, RoundResult, Suit } from "./hokm/types";
import { avatarEmoji } from "./online/types";
const KOT_POINTS = 2;
// Animation/pacing timings (ms) — UI matches these.
export const TIMING = {
hakemDraw: 1500,
aiTrump: 1000,
aiPlay: 850,
trickPause: 1150,
roundPause: 2600,
} as const;
export type GameMode = "ai" | "online";
export interface SeatPlayer {
name: string;
avatar: string; // emoji
level: number;
}
export interface GameSettings {
names: [string, string, string, string];
targetScore: number;
}
export interface OnlineMatchConfig {
players: { displayName: string; avatar: string; level: number }[]; // index = seat
targetScore: number;
stake: number;
ranked: boolean;
}
interface MatchTally {
tricksTeam0: number;
kotFor: boolean; // your team kot'd opponents at least once
kotAgainst: boolean;
}
interface GameStore {
game: GameState;
started: boolean;
mode: GameMode;
seatPlayers: SeatPlayer[];
matchMeta: { ranked: boolean; stake: number };
tally: MatchTally;
newMatch: (settings: GameSettings) => void;
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
chooseTrump: (suit: Suit) => void;
playHuman: (card: Card) => void;
reset: () => void;
}
const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"];
let pending: ReturnType<typeof setTimeout> | null = null;
function clearPending() {
if (pending) {
clearTimeout(pending);
pending = null;
}
}
function freshTally(): MatchTally {
return { tricksTeam0: 0, kotFor: false, kotAgainst: false };
}
export const useGameStore = create<GameStore>((set, get) => {
function recordRound(result: RoundResult | null) {
if (!result) return;
const t = get().tally;
set({
tally: {
tricksTeam0: t.tricksTeam0 + result.tricks[0],
kotFor: t.kotFor || (result.winningTeam === 0 && result.kot),
kotAgainst: t.kotAgainst || (result.winningTeam === 1 && result.kot),
},
});
}
function scheduleAuto() {
clearPending();
const g = get().game;
switch (g.phase) {
case "selecting-hakem":
pending = setTimeout(() => {
set({ game: dealForTrump(get().game) });
scheduleAuto();
}, TIMING.hakemDraw);
break;
case "choosing-trump": {
const hakem = g.hakem!;
if (!g.players[hakem].isHuman) {
pending = setTimeout(() => {
const cur = get().game;
const suit = chooseTrumpAI(cur.players[cur.hakem!].hand);
set({ game: engineChooseTrump(cur, suit) });
scheduleAuto();
}, TIMING.aiTrump);
}
break;
}
case "playing": {
const seat = g.turn!;
if (!g.players[seat].isHuman) {
pending = setTimeout(() => {
const cur = get().game;
const s = cur.turn!;
const card = chooseCardAI(cur, s);
set({ game: playCard(cur, s, card) });
scheduleAuto();
}, TIMING.aiPlay);
}
break;
}
case "trick-complete":
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":
pending = setTimeout(() => {
recordRound(get().game.lastRoundResult);
set({ game: startNextRound(get().game) });
scheduleAuto();
}, TIMING.roundPause);
break;
default:
break;
}
}
return {
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
started: false,
mode: "ai",
seatPlayers: [],
matchMeta: { ranked: false, stake: 0 },
tally: freshTally(),
newMatch: (settings) => {
clearPending();
const initial = createInitialState(settings);
set({
game: selectHakem(initial),
started: true,
mode: "ai",
matchMeta: { ranked: false, stake: 0 },
tally: freshTally(),
seatPlayers: settings.names.map((name, i) => ({
name,
avatar: AI_AVATARS[i],
level: 0,
})),
});
scheduleAuto();
},
newOnlineMatch: (cfg) => {
clearPending();
const names = cfg.players.map((p) => p.displayName) as GameSettings["names"];
const initial = createInitialState({ names, targetScore: cfg.targetScore });
set({
game: selectHakem(initial),
started: true,
mode: "online",
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
tally: freshTally(),
seatPlayers: cfg.players.map((p) => ({
name: p.displayName,
avatar: avatarEmoji(p.avatar),
level: p.level,
})),
});
scheduleAuto();
},
chooseTrump: (suit) => {
const g = get().game;
if (g.phase !== "choosing-trump") return;
set({ game: engineChooseTrump(g, suit) });
scheduleAuto();
},
playHuman: (card) => {
const g = get().game;
if (g.phase !== "playing" || g.turn !== 0) return;
set({ game: playCard(g, 0, card) });
scheduleAuto();
},
reset: () => {
clearPending();
set({
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
started: false,
mode: "ai",
seatPlayers: [],
tally: freshTally(),
});
},
};
});
+72
View File
@@ -0,0 +1,72 @@
import { legalMoves, trickWinner } from "./engine";
import { Card, GameState, Seat, Suit, SUITS, teamOf } from "./types";
/** Pick trump from the hakem's opening cards: longest suit, break ties by strength. */
export function chooseTrumpAI(hand: Card[]): Suit {
let best: Suit = "spades";
let bestScore = -1;
for (const suit of SUITS) {
const cards = hand.filter((c) => c.suit === suit);
// weight count heavily, add rank strength as a tie-breaker
const strength = cards.reduce((s, c) => s + Math.max(0, c.rank - 9), 0);
const score = cards.length * 10 + strength;
if (score > bestScore) {
bestScore = score;
best = suit;
}
}
return best;
}
function lowestRank(cards: Card[]): Card {
return cards.reduce((lo, c) => (c.rank < lo.rank ? c : lo));
}
/** Prefer dumping low non-trump; keep trump for when it matters. */
function dumpCard(legal: Card[], trump: Suit | null): Card {
const nonTrump = legal.filter((c) => c.suit !== trump);
const pool = nonTrump.length > 0 ? nonTrump : legal;
return lowestRank(pool);
}
/** Decide which card the AI at `seat` should play. */
export function chooseCardAI(state: GameState, seat: Seat): Card {
const legal = legalMoves(state, seat);
if (legal.length === 1) return legal[0];
const trump = state.trump;
const trick = state.currentTrick;
// Leading the trick.
if (trick.length === 0) {
const nonTrump = legal.filter((c) => c.suit !== trump);
const aces = nonTrump.filter((c) => c.rank === 14);
if (aces.length > 0) return aces[0];
// Lead a low non-trump to probe; keep aces/trump in reserve.
if (nonTrump.length > 0) return lowestRank(nonTrump);
return lowestRank(legal);
}
// Following.
const best = trick.reduce((b, pc) =>
trickWinner([b, pc], trump) === pc.seat ? pc : b
);
const partnerWinning = teamOf(best.seat) === teamOf(seat);
const winningCards = legal.filter(
(card) => trickWinner([...trick, { seat, card }], trump) === seat
);
if (partnerWinning) {
// Partner already winning — don't waste a high card.
return dumpCard(legal, trump);
}
if (winningCards.length > 0) {
// Win as cheaply as possible.
return lowestRank(winningCards);
}
// Can't win — discard the cheapest card.
return dumpCard(legal, trump);
}
+30
View File
@@ -0,0 +1,30 @@
import { Card, RANKS, SUITS } from "./types";
export function createDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push({ suit, rank, id: `${suit}-${rank}` });
}
}
return deck;
}
/** FisherYates shuffle. Returns a new array. */
export function shuffle<T>(input: readonly T[]): T[] {
const arr = input.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/** Sort a hand for display: group by suit, high rank first. */
export function sortHand(hand: Card[]): Card[] {
const suitOrder = { spades: 0, hearts: 1, clubs: 2, diamonds: 3 };
return hand.slice().sort((a, b) => {
if (a.suit !== b.suit) return suitOrder[a.suit] - suitOrder[b.suit];
return b.rank - a.rank;
});
}
+293
View File
@@ -0,0 +1,293 @@
import { createDeck, shuffle } from "./deck";
import {
Card,
GameState,
PlayedCard,
Player,
RoundResult,
Seat,
Suit,
Team,
nextSeat,
partnerOf,
teamOf,
} from "./types";
export const TRICKS_TO_WIN_ROUND = 7;
export interface MatchOptions {
names: [string, string, string, string];
/** rounds needed to win the match */
targetScore?: number;
/** double points when opponents take zero tricks */
kotPoints?: number;
}
function makePlayers(names: [string, string, string, string]): Player[] {
return ([0, 1, 2, 3] as Seat[]).map((seat) => ({
seat,
name: names[seat],
isHuman: seat === 0,
team: teamOf(seat),
hand: [],
}));
}
export function createInitialState(opts: MatchOptions): GameState {
return {
phase: "idle",
players: makePlayers(opts.names),
deck: [],
hakem: null,
trump: null,
turn: null,
currentTrick: [],
leadSeat: null,
roundTricks: [0, 0],
matchScore: [0, 0],
lastTrickWinner: null,
lastRoundResult: null,
matchWinner: null,
hakemDraw: [],
targetScore: opts.targetScore ?? 7,
dealId: 0,
};
}
/**
* Draw cards face-up, one per seat in rotation starting at seat 0,
* until an Ace appears. That seat becomes the first hakem.
*/
export function selectHakem(state: GameState): GameState {
const deck = shuffle(createDeck());
const draws: PlayedCard[] = [];
let seat: Seat = 0;
let hakem: Seat = 0;
for (const card of deck) {
draws.push({ seat, card });
if (card.rank === 14) {
hakem = seat;
break;
}
seat = nextSeat(seat);
}
return {
...state,
phase: "selecting-hakem",
hakem,
hakemDraw: draws,
};
}
/**
* Deal the opening 5 cards to the hakem so they can choose trump.
* Remaining cards stay in the deck for the post-trump deal.
*/
export function dealForTrump(state: GameState): GameState {
if (state.hakem == null) throw new Error("hakem not selected");
const deck = shuffle(createDeck());
const players = state.players.map((p) => ({ ...p, hand: [] as Card[] }));
const first5 = deck.slice(0, 5);
players[state.hakem].hand = first5;
return {
...state,
phase: "choosing-trump",
players,
deck: deck.slice(5),
trump: null,
turn: state.hakem,
currentTrick: [],
leadSeat: null,
roundTricks: [0, 0],
lastTrickWinner: null,
lastRoundResult: null,
hakemDraw: [],
dealId: state.dealId + 1,
};
}
/** Hakem locks in the trump suit; remaining cards are dealt out (13 each). */
export function chooseTrump(state: GameState, trump: Suit): GameState {
if (state.phase !== "choosing-trump") throw new Error("not choosing trump");
if (state.hakem == null) throw new Error("hakem not selected");
const players = state.players.map((p) => ({ ...p, hand: p.hand.slice() }));
const deck = state.deck.slice();
// Hakem already has 5 — give 8 more. Others get 13.
for (const p of players) {
const need = 13 - p.hand.length;
p.hand.push(...deck.splice(0, need));
}
return {
...state,
phase: "playing",
trump,
players,
deck,
turn: state.hakem,
leadSeat: state.hakem,
currentTrick: [],
};
}
/** Cards a seat is allowed to play right now (follow-suit rule). */
export function legalMoves(state: GameState, seat: Seat): Card[] {
const hand = state.players[seat].hand;
if (state.currentTrick.length === 0) return hand;
const leadSuit = state.currentTrick[0].card.suit;
const sameSuit = hand.filter((c) => c.suit === leadSuit);
return sameSuit.length > 0 ? sameSuit : hand;
}
export function isLegalPlay(state: GameState, seat: Seat, card: Card): boolean {
if (state.turn !== seat) return false;
return legalMoves(state, seat).some((c) => c.id === card.id);
}
/** Determine which played card wins a completed (or partial) trick. */
export function trickWinner(trick: PlayedCard[], trump: Suit | null): Seat {
if (trick.length === 0) throw new Error("empty trick");
const leadSuit = trick[0].card.suit;
let best = trick[0];
for (const pc of trick.slice(1)) {
const bestIsTrump = trump != null && best.card.suit === trump;
const pcIsTrump = trump != null && pc.card.suit === trump;
if (pcIsTrump && !bestIsTrump) {
best = pc;
} else if (pcIsTrump === bestIsTrump) {
// same trump-ness: higher rank wins, but only if following the lead/trump suit
const relevantSuit = bestIsTrump ? trump : leadSuit;
if (pc.card.suit === relevantSuit && pc.card.rank > best.card.rank) {
best = pc;
}
}
}
return best.seat;
}
/**
* Play a card. Returns the new state. When the trick completes (4 cards),
* phase becomes "trick-complete" and lastTrickWinner is set; call
* advanceAfterTrick() to collect it and continue.
*/
export function playCard(state: GameState, seat: Seat, card: Card): GameState {
if (!isLegalPlay(state, seat, card)) {
throw new Error(`illegal play: seat ${seat} ${card.id}`);
}
const players = state.players.map((p) =>
p.seat === seat ? { ...p, hand: p.hand.filter((c) => c.id !== card.id) } : p
);
const currentTrick = [...state.currentTrick, { seat, card }];
const leadSeat = state.leadSeat ?? seat;
if (currentTrick.length < 4) {
return {
...state,
players,
currentTrick,
leadSeat,
turn: nextSeat(seat),
};
}
// Trick complete.
const winner = trickWinner(currentTrick, state.trump);
return {
...state,
players,
currentTrick,
leadSeat,
turn: null,
phase: "trick-complete",
lastTrickWinner: winner,
};
}
function buildRoundResult(
roundTricks: [number, number],
winningTeam: Team,
kotPoints: number
): RoundResult {
const loser = (1 - winningTeam) as Team;
const kot = roundTricks[loser] === 0;
return {
winningTeam,
tricks: roundTricks,
kot,
points: kot ? kotPoints : 1,
};
}
/**
* Collect the finished trick: credit the winner, then either continue the
* round, end the round, or end the match.
*/
export function advanceAfterTrick(
state: GameState,
kotPoints = 2
): GameState {
if (state.phase !== "trick-complete" || state.lastTrickWinner == null) {
return state;
}
const winner = state.lastTrickWinner;
const wTeam = teamOf(winner);
const roundTricks: [number, number] = [...state.roundTricks];
roundTricks[wTeam] += 1;
const someoneWonRound = roundTricks[wTeam] >= TRICKS_TO_WIN_ROUND;
if (someoneWonRound) {
const result = buildRoundResult(roundTricks, wTeam, kotPoints);
const matchScore: [number, number] = [...state.matchScore];
matchScore[wTeam] += result.points;
const matchWinner =
matchScore[wTeam] >= state.targetScore ? wTeam : null;
return {
...state,
roundTricks,
matchScore,
currentTrick: [],
lastTrickWinner: winner,
lastRoundResult: result,
matchWinner,
turn: null,
phase: matchWinner != null ? "match-over" : "round-over",
};
}
return {
...state,
roundTricks,
currentTrick: [],
leadSeat: winner,
turn: winner,
lastTrickWinner: winner,
phase: "playing",
};
}
/**
* Start the next round after one ends. Hakem stays if their team won the
* round; otherwise it passes to the next seat. Deals fresh cards for trump.
*/
export function startNextRound(state: GameState): GameState {
if (state.hakem == null) throw new Error("no hakem");
const result = state.lastRoundResult;
let hakem = state.hakem;
if (result && teamOf(hakem) !== result.winningTeam) {
hakem = nextSeat(hakem);
}
return dealForTrump({ ...state, hakem });
}
/** Convenience: did the hakem's team win the just-finished round? */
export function hakemHeld(state: GameState): boolean {
if (state.hakem == null || !state.lastRoundResult) return false;
return teamOf(state.hakem) === state.lastRoundResult.winningTeam;
}
export { partnerOf, teamOf, nextSeat };
+134
View File
@@ -0,0 +1,134 @@
// Core Hokm domain types — framework-agnostic, no React/DOM imports.
export type Suit = "spades" | "hearts" | "diamonds" | "clubs";
// 2..10, then J Q K A (Ace high in Hokm)
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14;
export interface Card {
suit: Suit;
rank: Rank;
/** stable id, e.g. "spades-14" */
id: string;
}
/** Seats are clockwise: 0 (you/bottom), 1 (right), 2 (top), 3 (left). */
export type Seat = 0 | 1 | 2 | 3;
/** Teams: team 0 = seats 0 & 2, team 1 = seats 1 & 3. */
export type Team = 0 | 1;
export interface Player {
seat: Seat;
name: string;
isHuman: boolean;
team: Team;
hand: Card[];
}
export type Phase =
| "idle" // before a match starts
| "selecting-hakem" // drawing for first Ace
| "choosing-trump" // hakem picks hokm suit
| "playing" // tricks in progress
| "trick-complete" // brief pause showing trick winner
| "round-over" // a 13-trick round finished
| "match-over"; // someone reached target round score
export interface PlayedCard {
seat: Seat;
card: Card;
}
export interface RoundResult {
winningTeam: Team;
/** trick counts at round end, indexed by team */
tricks: [number, number];
/** true if losing team took zero tricks */
kot: boolean;
/** points awarded to winning team this round */
points: number;
}
export interface GameState {
phase: Phase;
players: Player[];
/** Undealt cards remaining (server-authoritative; ignored by UI). */
deck: Card[];
/** The hakem ( حاکم) seat — leads first trick, chose trump. */
hakem: Seat | null;
trump: Suit | null;
/** Whose turn it is to act (play a card, or choose trump). */
turn: Seat | null;
/** Cards on the table for the current trick, in play order. */
currentTrick: PlayedCard[];
/** Seat that led the current trick. */
leadSeat: Seat | null;
/** Tricks won this round, by team. */
roundTricks: [number, number];
/** Rounds (points) won across the match, by team. */
matchScore: [number, number];
/** Winner of the last completed trick (for the pause/animation). */
lastTrickWinner: Seat | null;
lastRoundResult: RoundResult | null;
matchWinner: Team | null;
/** Cards revealed during hakem selection (face-up draw). */
hakemDraw: PlayedCard[];
/** Points required to win the match (rounds). */
targetScore: number;
/** Increment to help the UI key animations per deal. */
dealId: number;
}
export const SUITS: Suit[] = ["spades", "hearts", "diamonds", "clubs"];
export const RANKS: Rank[] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
export const SUIT_SYMBOL: Record<Suit, string> = {
spades: "♠",
hearts: "♥",
diamonds: "♦",
clubs: "♣",
};
export const SUIT_IS_RED: Record<Suit, boolean> = {
spades: false,
hearts: true,
diamonds: true,
clubs: false,
};
export function rankLabel(rank: Rank): string {
switch (rank) {
case 14:
return "A";
case 13:
return "K";
case 12:
return "Q";
case 11:
return "J";
default:
return String(rank);
}
}
export function teamOf(seat: Seat): Team {
return (seat % 2) as Team;
}
export function partnerOf(seat: Seat): Seat {
return ((seat + 2) % 4) as Seat;
}
export function nextSeat(seat: Seat): Seat {
return ((seat + 1) % 4) as Seat;
}
+435
View File
@@ -0,0 +1,435 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
export type Locale = "fa" | "en";
type Dict = Record<string, string>;
const fa: Dict = {
"app.title": "حکم",
"app.subtitle": "بازی کارت اصیل ایرانی",
"app.tagline": "تجربه‌ای لوکس از بازی حکم، با حریف‌های هوشمند",
"home.play": "شروع بازی",
"home.continue": "ادامه بازی",
"home.vsAI": "بازی با کامپیوتر",
"home.target": "امتیاز برد",
"home.targetHint": "تعداد دست برای برنده شدن",
"home.yourName": "نام شما",
"home.start": "بزن بریم",
"home.howTo": "آموزش بازی",
"home.lang": "English",
"seat.you": "شما",
"team.us": "ما",
"team.them": "حریف",
"team.0": "تیم ما",
"team.1": "تیم حریف",
"hakem.title": "تعیین حاکم",
"hakem.desc": "ورق می‌چینیم تا اولین آس بیاید",
"hakem.is": "حاکم: {name}",
"trump.title": "حکم را انتخاب کنید",
"trump.desc": "شما حاکم هستید — خال حکم را تعیین کنید",
"trump.waiting": "{name} در حال انتخاب حکم است…",
"trump.label": "حکم",
"turn.you": "نوبت شماست",
"turn.other": "نوبت {name}",
"trick.wins": "{name} دست را برد",
"round.over": "پایان دست",
"round.kot": "کُت! ",
"round.won": "{team} برنده شد",
"round.score": "امتیاز: {us} - {them}",
"round.next": "دست بعد…",
"match.over": "پایان بازی",
"match.youWin": "شما بردید! 🏆",
"match.youLose": "این بار باختید",
"match.again": "بازی دوباره",
"match.menu": "منوی اصلی",
"score.title": "امتیاز",
"score.tricks": "دست‌ها",
"hud.menu": "منو",
"hud.quit": "خروج",
"menu.vsComputer": "بازی با کامپیوتر",
"menu.vsComputerDesc": "تمرین با حریف‌های هوشمند",
"menu.online": "بازی آنلاین",
"menu.onlineDesc": "با دوستان یا بازیکن‌های واقعی",
"menu.profile": "پروفایل",
"menu.friends": "دوستان",
"menu.leaderboard": "جدول امتیازات",
"menu.shop": "فروشگاه",
"menu.signIn": "ورود / ثبت‌نام",
"menu.guest": "مهمان",
"menu.signOut": "خروج از حساب",
"common.back": "بازگشت",
"common.coins": "سکه",
"common.level": "سطح",
"common.rating": "امتیاز",
"common.save": "ذخیره",
"common.cancel": "انصراف",
"common.confirm": "تأیید",
"common.soon": "به‌زودی",
"common.copy": "کپی",
"common.copied": "کپی شد",
"profile.title": "پروفایل",
"profile.stats": "آمار",
"profile.games": "بازی‌ها",
"profile.wins": "بردها",
"profile.winrate": "درصد برد",
"profile.kots": "کُت‌ها",
"profile.streak": "بهترین نوار",
"profile.achievements": "دستاوردها",
"profile.editName": "ویرایش نام",
"profile.chooseAvatar": "انتخاب آواتار",
"friends.title": "دوستان",
"friends.add": "افزودن",
"friends.addPlaceholder": "نام کاربری یا شماره",
"friends.requests": "درخواست‌ها",
"friends.online": "آنلاین",
"friends.offline": "آفلاین",
"friends.inGame": "در حال بازی",
"friends.invite": "دعوت",
"friends.accept": "قبول",
"friends.decline": "رد",
"friends.remove": "حذف",
"friends.empty": "هنوز دوستی ندارید",
"lobby.title": "بازی آنلاین",
"lobby.createRoom": "ساخت اتاق خصوصی",
"lobby.createDesc": "هم‌تیمی و حریف‌ها را خودتان انتخاب کنید",
"lobby.random": "بازی رتبه‌ای",
"lobby.randomDesc": "حریف تصادفی و کسب امتیاز و سکه",
"room.title": "اتاق بازی",
"room.code": "کد اتاق",
"room.partner": "هم‌تیمی",
"room.opponents": "حریف‌ها",
"room.choosePartner": "انتخاب هم‌تیمی",
"room.invite": "دعوت دوست",
"room.addBot": "ربات",
"room.empty": "خالی",
"room.waiting": "در انتظار…",
"room.start": "شروع بازی",
"room.stake": "شرط",
"room.leave": "ترک اتاق",
"room.pickFriend": "یک دوست را انتخاب کنید",
"mm.title": "جستجوی بازیکن",
"mm.searching": "در حال یافتن حریف…",
"mm.found": "بازیکنان پیدا شدند!",
"mm.ready": "آماده شروع",
"mm.cancel": "لغو",
"mm.start": "ورود به بازی",
"lead.title": "جدول امتیازات",
"lead.rank": "رتبه",
"shop.title": "فروشگاه",
"shop.buy": "خرید",
"shop.owned": "موجود",
"shop.avatars": "آواتارها",
"shop.themes": "تم‌ها",
"shop.notEnough": "سکه کافی نیست",
"auth.title": "ورود به حکم",
"auth.subtitle": "برای بازی آنلاین وارد شوید",
"auth.phone": "موبایل",
"auth.email": "ایمیل",
"auth.phoneLabel": "شماره موبایل",
"auth.phonePlaceholder": "۰۹۱۲۳۴۵۶۷۸۹",
"auth.sendCode": "ارسال کد",
"auth.codeLabel": "کد تأیید",
"auth.codePlaceholder": "کد ۴ رقمی",
"auth.verify": "تأیید و ورود",
"auth.devCode": "کد آزمایشی: {code}",
"auth.emailLabel": "ایمیل",
"auth.passLabel": "رمز عبور",
"auth.nameLabel": "نام نمایشی",
"auth.signIn": "ورود",
"auth.signUp": "ثبت‌نام",
"auth.google": "ورود با گوگل",
"auth.toggleSignup": "حساب ندارید؟ ثبت‌نام کنید",
"auth.toggleSignin": "حساب دارید؟ وارد شوید",
"auth.invalidCode": "کد نادرست است",
"reward.title": "پاداش بازی",
"reward.rating": "امتیاز رتبه‌ای",
"reward.coins": "سکه",
"reward.xp": "تجربه",
"reward.levelUp": "ارتقای سطح!",
"reward.promoted": "ارتقای لیگ!",
"reward.demoted": "سقوط لیگ",
"reward.newAchievement": "دستاورد جدید",
"reward.continue": "ادامه",
"reward.win": "بردید! 🏆",
"reward.lose": "باختید",
"daily.title": "پاداش روزانه",
"daily.day": "روز {n}",
"daily.claim": "دریافت",
"daily.claimed": "دریافت شد",
"daily.come": "فردا برگردید",
"rank.label": "لیگ",
};
const en: Dict = {
"app.title": "Hokm",
"app.subtitle": "The classic Persian card game",
"app.tagline": "A luxury Hokm experience with smart opponents",
"home.play": "Play",
"home.continue": "Continue",
"home.vsAI": "Play vs Computer",
"home.target": "Target score",
"home.targetHint": "Rounds needed to win",
"home.yourName": "Your name",
"home.start": "Let's go",
"home.howTo": "How to play",
"home.lang": "فارسی",
"seat.you": "You",
"team.us": "Us",
"team.them": "Them",
"team.0": "Our team",
"team.1": "Their team",
"hakem.title": "Choosing the Hakem",
"hakem.desc": "Dealing face-up until the first Ace",
"hakem.is": "Hakem: {name}",
"trump.title": "Choose the trump",
"trump.desc": "You are the Hakem — pick the trump suit",
"trump.waiting": "{name} is choosing trump…",
"trump.label": "Trump",
"turn.you": "Your turn",
"turn.other": "{name}'s turn",
"trick.wins": "{name} wins the trick",
"round.over": "Round over",
"round.kot": "Kot! ",
"round.won": "{team} wins",
"round.score": "Score: {us} - {them}",
"round.next": "Next round…",
"match.over": "Game over",
"match.youWin": "You win! 🏆",
"match.youLose": "You lost this time",
"match.again": "Play again",
"match.menu": "Main menu",
"score.title": "Score",
"score.tricks": "Tricks",
"hud.menu": "Menu",
"hud.quit": "Quit",
"menu.vsComputer": "Play vs Computer",
"menu.vsComputerDesc": "Practice against smart bots",
"menu.online": "Play Online",
"menu.onlineDesc": "With friends or real players",
"menu.profile": "Profile",
"menu.friends": "Friends",
"menu.leaderboard": "Leaderboard",
"menu.shop": "Shop",
"menu.signIn": "Sign in / Sign up",
"menu.guest": "Guest",
"menu.signOut": "Sign out",
"common.back": "Back",
"common.coins": "Coins",
"common.level": "Level",
"common.rating": "Rating",
"common.save": "Save",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.soon": "Coming soon",
"common.copy": "Copy",
"common.copied": "Copied",
"profile.title": "Profile",
"profile.stats": "Stats",
"profile.games": "Games",
"profile.wins": "Wins",
"profile.winrate": "Win rate",
"profile.kots": "Kots",
"profile.streak": "Best streak",
"profile.achievements": "Achievements",
"profile.editName": "Edit name",
"profile.chooseAvatar": "Choose avatar",
"friends.title": "Friends",
"friends.add": "Add",
"friends.addPlaceholder": "Username or phone",
"friends.requests": "Requests",
"friends.online": "Online",
"friends.offline": "Offline",
"friends.inGame": "In game",
"friends.invite": "Invite",
"friends.accept": "Accept",
"friends.decline": "Decline",
"friends.remove": "Remove",
"friends.empty": "No friends yet",
"lobby.title": "Play Online",
"lobby.createRoom": "Create private room",
"lobby.createDesc": "Choose your partner and opponents",
"lobby.random": "Ranked match",
"lobby.randomDesc": "Random opponents, earn rating & coins",
"room.title": "Game Room",
"room.code": "Room code",
"room.partner": "Partner",
"room.opponents": "Opponents",
"room.choosePartner": "Choose partner",
"room.invite": "Invite friend",
"room.addBot": "Bot",
"room.empty": "Empty",
"room.waiting": "Waiting…",
"room.start": "Start game",
"room.stake": "Stake",
"room.leave": "Leave room",
"room.pickFriend": "Pick a friend",
"mm.title": "Finding players",
"mm.searching": "Searching for opponents…",
"mm.found": "Players found!",
"mm.ready": "Ready to start",
"mm.cancel": "Cancel",
"mm.start": "Enter game",
"lead.title": "Leaderboard",
"lead.rank": "Rank",
"shop.title": "Shop",
"shop.buy": "Buy",
"shop.owned": "Owned",
"shop.avatars": "Avatars",
"shop.themes": "Themes",
"shop.notEnough": "Not enough coins",
"auth.title": "Sign in to Hokm",
"auth.subtitle": "Sign in to play online",
"auth.phone": "Phone",
"auth.email": "Email",
"auth.phoneLabel": "Mobile number",
"auth.phonePlaceholder": "0912 345 6789",
"auth.sendCode": "Send code",
"auth.codeLabel": "Verification code",
"auth.codePlaceholder": "4-digit code",
"auth.verify": "Verify & sign in",
"auth.devCode": "Dev code: {code}",
"auth.emailLabel": "Email",
"auth.passLabel": "Password",
"auth.nameLabel": "Display name",
"auth.signIn": "Sign in",
"auth.signUp": "Sign up",
"auth.google": "Continue with Google",
"auth.toggleSignup": "No account? Sign up",
"auth.toggleSignin": "Have an account? Sign in",
"auth.invalidCode": "Invalid code",
"reward.title": "Match rewards",
"reward.rating": "Rating",
"reward.coins": "Coins",
"reward.xp": "XP",
"reward.levelUp": "Level up!",
"reward.promoted": "Promoted!",
"reward.demoted": "Demoted",
"reward.newAchievement": "New achievement",
"reward.continue": "Continue",
"reward.win": "You won! 🏆",
"reward.lose": "You lost",
"daily.title": "Daily reward",
"daily.day": "Day {n}",
"daily.claim": "Claim",
"daily.claimed": "Claimed",
"daily.come": "Come back tomorrow",
"rank.label": "League",
};
const DICTS: Record<Locale, Dict> = { fa, en };
interface I18nValue {
locale: Locale;
dir: "rtl" | "ltr";
t: (key: string, vars?: Record<string, string | number>) => string;
setLocale: (l: Locale) => void;
toggle: () => void;
}
const I18nContext = createContext<I18nValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("fa");
useEffect(() => {
const saved = localStorage.getItem("hokm.locale") as Locale | null;
if (saved === "fa" || saved === "en") setLocaleState(saved);
}, []);
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
localStorage.setItem("hokm.locale", l);
}, []);
const dir: "rtl" | "ltr" = locale === "fa" ? "rtl" : "ltr";
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
}, [locale, dir]);
const t = useCallback(
(key: string, vars?: Record<string, string | number>) => {
let str = DICTS[locale][key] ?? DICTS.en[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
str = str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
}
}
return str;
},
[locale]
);
const value = useMemo<I18nValue>(
() => ({
locale,
dir,
t,
setLocale,
toggle: () => setLocale(locale === "fa" ? "en" : "fa"),
}),
[locale, dir, t, setLocale]
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
return ctx;
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { create } from "zustand";
import { CreateRoomOptions, MatchmakingOptions, getService } from "./online/service";
import {
Friend,
FriendRequest,
LeaderboardEntry,
MatchmakingState,
Room,
} from "./online/types";
interface OnlineStore {
friends: Friend[];
requests: FriendRequest[];
room: Room | null;
matchmaking: MatchmakingState;
leaderboard: LeaderboardEntry[];
loadFriends: () => Promise<void>;
addFriend: (q: string) => Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
acceptRequest: (id: string) => Promise<void>;
declineRequest: (id: string) => Promise<void>;
removeFriend: (id: string) => Promise<void>;
createRoom: (opts: CreateRoomOptions) => Promise<void>;
setPartner: (friendId: string | null) => Promise<void>;
inviteToSeat: (seat: 1 | 3, friendId: string) => Promise<void>;
addBot: (seat: 1 | 2 | 3) => Promise<void>;
clearSeat: (seat: 1 | 2 | 3) => Promise<void>;
startRoom: () => Promise<void>;
leaveRoom: () => Promise<void>;
startMatchmaking: (opts: MatchmakingOptions) => Promise<void>;
cancelMatchmaking: () => Promise<void>;
loadLeaderboard: () => Promise<void>;
}
let roomUnsub: (() => void) | null = null;
let mmUnsub: (() => void) | null = null;
let friendUnsub: (() => void) | null = null;
export const useOnlineStore = create<OnlineStore>((set, get) => ({
friends: [],
requests: [],
room: null,
matchmaking: { phase: "idle", players: [], elapsedMs: 0, ranked: true, stake: 0 },
leaderboard: [],
loadFriends: async () => {
const svc = getService();
const [friends, requests] = await Promise.all([svc.listFriends(), svc.listRequests()]);
set({ friends, requests });
if (!friendUnsub) friendUnsub = svc.onFriends((f) => set({ friends: f }));
},
addFriend: async (q) => {
const res = await getService().addFriend(q);
if (res.ok) await get().loadFriends();
return res;
},
acceptRequest: async (id) => {
await getService().acceptRequest(id);
const requests = await getService().listRequests();
set({ requests });
},
declineRequest: async (id) => {
await getService().declineRequest(id);
set({ requests: get().requests.filter((r) => r.id !== id) });
},
removeFriend: async (id) => {
await getService().removeFriend(id);
},
createRoom: async (opts) => {
const svc = getService();
const room = await svc.createRoom(opts);
set({ room });
if (roomUnsub) roomUnsub();
roomUnsub = svc.onRoom((r) => set({ room: { ...r } }));
},
setPartner: async (friendId) => {
const r = await getService().setPartner(get().room!.id, friendId);
set({ room: { ...r } });
},
inviteToSeat: async (seat, friendId) => {
const r = await getService().inviteToSeat(get().room!.id, seat, friendId);
set({ room: { ...r } });
},
addBot: async (seat) => {
const r = await getService().addBot(get().room!.id, seat);
set({ room: { ...r } });
},
clearSeat: async (seat) => {
const r = await getService().clearSeat(get().room!.id, seat);
set({ room: { ...r } });
},
startRoom: async () => {
const r = await getService().startRoom(get().room!.id);
set({ room: { ...r } });
},
leaveRoom: async () => {
if (get().room) await getService().leaveRoom(get().room!.id);
if (roomUnsub) {
roomUnsub();
roomUnsub = null;
}
set({ room: null });
},
startMatchmaking: async (opts) => {
const svc = getService();
if (mmUnsub) mmUnsub();
mmUnsub = svc.onMatchmaking((s) => set({ matchmaking: s }));
await svc.startMatchmaking(opts);
},
cancelMatchmaking: async () => {
await getService().cancelMatchmaking();
if (mmUnsub) {
mmUnsub();
mmUnsub = null;
}
set({ matchmaking: { phase: "idle", players: [], elapsedMs: 0, ranked: true, stake: 0 } });
},
loadLeaderboard: async () => {
const leaderboard = await getService().getLeaderboard();
set({ leaderboard });
},
}));
+286
View File
@@ -0,0 +1,286 @@
// Pure gamification rules: ranks/leagues, rating, XP/levels, coins,
// daily rewards, achievements. No side effects, no storage — unit-testable.
import {
AchievementDef,
AchievementUnlock,
LeagueInfo,
MatchSummary,
PlayerStats,
RankTier,
RankTierId,
RewardResult,
UserProfile,
} from "./types";
/* ------------------------------- Ranks ------------------------------- */
export const RANK_TIERS: RankTier[] = [
{ id: "bronze", nameFa: "برنز", nameEn: "Bronze", floor: 0, color: "#cd7f32" },
{ id: "silver", nameFa: "نقره", nameEn: "Silver", floor: 1100, color: "#c0c7d0" },
{ id: "gold", nameFa: "طلا", nameEn: "Gold", floor: 1300, color: "#e6b800" },
{ id: "platinum", nameFa: "پلاتین", nameEn: "Platinum", floor: 1500, color: "#46c2c2" },
{ id: "diamond", nameFa: "الماس", nameEn: "Diamond", floor: 1700, color: "#6aa6ff" },
{ id: "master", nameFa: "استاد", nameEn: "Master", floor: 1900, color: "#c77dff" },
];
const ROMAN = ["", "I", "II", "III"];
export function divisionLabel(division: number | null): string {
if (division == null) return "";
return ROMAN[division] ?? "";
}
export function tierById(id: RankTierId): RankTier {
return RANK_TIERS.find((t) => t.id === id) ?? RANK_TIERS[0];
}
export function getLeagueInfo(rating: number): LeagueInfo {
const r = Math.max(0, Math.round(rating));
let idx = 0;
for (let i = 0; i < RANK_TIERS.length; i++) {
if (r >= RANK_TIERS[i].floor) idx = i;
}
const tier = RANK_TIERS[idx];
const isLast = idx === RANK_TIERS.length - 1;
if (isLast) {
return { tier, division: null, rating: r, nextThreshold: null, progress: 1 };
}
const nextTierFloor = RANK_TIERS[idx + 1].floor;
const band = nextTierFloor - tier.floor;
const third = band / 3;
// division 3 (III) is lowest, 1 (I) is highest
const within = r - tier.floor;
let division: number;
let divStart: number;
let divEnd: number;
if (within < third) {
division = 3;
divStart = tier.floor;
divEnd = tier.floor + third;
} else if (within < 2 * third) {
division = 2;
divStart = tier.floor + third;
divEnd = tier.floor + 2 * third;
} else {
division = 1;
divStart = tier.floor + 2 * third;
divEnd = nextTierFloor;
}
const progress = Math.min(1, Math.max(0, (r - divStart) / (divEnd - divStart)));
return { tier, division, rating: r, nextThreshold: Math.round(divEnd), progress };
}
/* ------------------------------ Rating ------------------------------- */
const K_FACTOR = 32;
/** Elo-style rating delta for a ranked match (0 for casual). */
export function ratingDelta(
summary: MatchSummary,
myRating: number,
oppRating: number
): number {
if (!summary.ranked) return 0;
const expected = 1 / (1 + Math.pow(10, (oppRating - myRating) / 400));
const score = summary.won ? 1 : 0;
let delta = K_FACTOR * (score - expected);
if (summary.won && summary.kotFor) delta += 8;
if (!summary.won && summary.kotAgainst) delta -= 8;
const rounded = Math.round(delta);
// never let a win cost rating or a loss gain it
if (summary.won) return Math.max(1, rounded);
return Math.min(-1, rounded);
}
/* ------------------------------- Coins ------------------------------- */
export function coinDelta(summary: MatchSummary): number {
const base = summary.won ? (summary.ranked ? 50 : 25) : 10;
const stakeNet = summary.won ? summary.stake : -summary.stake;
const kotBonus = summary.won && summary.kotFor ? 40 : 0;
return base + stakeNet + kotBonus;
}
/* ------------------------------- XP ---------------------------------- */
/** XP required to advance from `level` to `level + 1`. */
export function xpNeededForLevel(level: number): number {
return 100 * level;
}
export function matchXp(summary: MatchSummary): number {
return (
40 +
(summary.won ? 80 : 0) +
summary.tricksWon * 5 +
(summary.kotFor ? 30 : 0)
);
}
export interface LevelProgress {
level: number;
xp: number; // xp within the current level
leveledUp: boolean;
}
export function addXp(level: number, xpInLevel: number, gained: number): LevelProgress {
let lvl = level;
let xp = xpInLevel + gained;
let leveledUp = false;
while (xp >= xpNeededForLevel(lvl)) {
xp -= xpNeededForLevel(lvl);
lvl += 1;
leveledUp = true;
}
return { level: lvl, xp, leveledUp };
}
/* --------------------------- Achievements ---------------------------- */
export const ACHIEVEMENTS: AchievementDef[] = [
{ id: "first_win", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game", icon: "🥇", goal: 1, coinReward: 100 },
{ id: "first_kot", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "حریف را کُت کنید", descEn: "Inflict a Kot on opponents", icon: "🔥", goal: 1, coinReward: 150 },
{ id: "wins_10", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games", icon: "🎯", goal: 10, coinReward: 300 },
{ id: "wins_100", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید", descEn: "Win 100 games", icon: "👑", goal: 100, coinReward: 2000 },
{ id: "streak_5", nameFa: "نوار ۵ برد", nameEn: "5 Win Streak", descFa: "۵ برد پیاپی", descEn: "Win 5 in a row", icon: "⚡", goal: 5, coinReward: 400 },
{ id: "reach_gold", nameFa: "رسیدن به طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league", icon: "🏅", goal: 1, coinReward: 500 },
{ id: "games_50", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games", icon: "🎮", goal: 50, coinReward: 350 },
];
/** Current raw progress value for an achievement from stats + rating. */
export function achievementProgress(
id: string,
stats: PlayerStats,
rating: number
): number {
switch (id) {
case "first_win":
return Math.min(1, stats.wins);
case "first_kot":
return Math.min(1, stats.kotsFor);
case "wins_10":
return Math.min(10, stats.wins);
case "wins_100":
return Math.min(100, stats.wins);
case "streak_5":
return Math.min(5, stats.bestWinStreak);
case "reach_gold":
return rating >= tierById("gold").floor ? 1 : 0;
case "games_50":
return Math.min(50, stats.games);
default:
return 0;
}
}
/* ---------------------- Apply a match result ------------------------- */
function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
const wins = stats.wins + (summary.won ? 1 : 0);
const losses = stats.losses + (summary.won ? 0 : 1);
const currentWinStreak = summary.won ? stats.currentWinStreak + 1 : 0;
return {
games: stats.games + 1,
wins,
losses,
kotsFor: stats.kotsFor + (summary.kotFor ? 1 : 0),
kotsAgainst: stats.kotsAgainst + (summary.kotAgainst ? 1 : 0),
tricks: stats.tricks + summary.tricksWon,
currentWinStreak,
bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak),
};
}
/**
* Apply a finished match to a profile. Returns a new profile + a RewardResult
* describing every delta for the post-match UI.
*/
export function applyMatchResult(
profile: UserProfile,
summary: MatchSummary,
oppRating: number
): { profile: UserProfile; reward: RewardResult } {
const ratingBefore = profile.rating;
const coinsBefore = profile.coins;
const levelBefore = profile.level;
const rDelta = ratingDelta(summary, profile.rating, oppRating);
const ratingAfter = Math.max(0, ratingBefore + rDelta);
const cDelta = coinDelta(summary);
const xpGain = matchXp(summary);
const lvl = addXp(profile.level, profile.xp, xpGain);
const stats = applyStats(profile.stats, summary);
// Evaluate achievements against the new state.
const achievements = { ...profile.achievements };
const unlocked = [...profile.unlocked];
const newAchievements: AchievementUnlock[] = [];
let achievementCoins = 0;
for (const def of ACHIEVEMENTS) {
const prog = achievementProgress(def.id, stats, ratingAfter);
achievements[def.id] = prog;
if (prog >= def.goal && !unlocked.includes(def.id)) {
unlocked.push(def.id);
achievementCoins += def.coinReward;
newAchievements.push({
id: def.id,
nameFa: def.nameFa,
nameEn: def.nameEn,
icon: def.icon,
coinReward: def.coinReward,
});
}
}
const coinsAfter = Math.max(0, coinsBefore + cDelta + achievementCoins);
const leagueBefore = getLeagueInfo(ratingBefore);
const leagueAfter = getLeagueInfo(ratingAfter);
const tierIndex = (id: RankTierId) => RANK_TIERS.findIndex((t) => t.id === id);
const rankValue = (l: LeagueInfo) =>
tierIndex(l.tier.id) * 10 - (l.division ?? 0);
const promoted = rankValue(leagueAfter) > rankValue(leagueBefore);
const demoted = rankValue(leagueAfter) < rankValue(leagueBefore);
const newProfile: UserProfile = {
...profile,
rating: ratingAfter,
coins: coinsAfter,
level: lvl.level,
xp: lvl.xp,
stats,
achievements,
unlocked,
};
const reward: RewardResult = {
ratingBefore,
ratingAfter,
ratingDelta: ratingAfter - ratingBefore,
coinsBefore,
coinsAfter,
coinsDelta: coinsAfter - coinsBefore,
xpGained: xpGain,
levelBefore,
levelAfter: lvl.level,
leveledUp: lvl.level > levelBefore,
newAchievements,
promoted,
demoted,
};
return { profile: newProfile, reward };
}
/* --------------------------- Daily reward ---------------------------- */
export const DAILY_REWARDS = [100, 150, 200, 300, 400, 500, 1000];
export function dailyRewardFor(day: number): number {
return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100;
}
+607
View File
@@ -0,0 +1,607 @@
// In-memory + localStorage mock implementing OnlineService.
// Simulates remote players, friends presence, room invites and matchmaking
// with timers, and computes rewards via gamification.ts.
import { applyMatchResult, dailyRewardFor } from "./gamification";
import {
CreateRoomOptions,
MatchmakingOptions,
OnlineService,
Unsubscribe,
} from "./service";
import {
AVATARS,
AuthSession,
DailyRewardState,
Friend,
FriendRequest,
LeaderboardEntry,
MatchSummary,
MatchmakingState,
PresenceStatus,
RewardResult,
Room,
RoomSeat,
ShopItem,
UserProfile,
} from "./types";
const PERSIAN_NAMES = [
"آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا",
"الناز", "بابک", "شیما", "حسام", "تینا", "کاوه", "رویا", "مازیار",
"نگار", "سهراب", "بهار", "فرهاد", "یاسمن", "آرمان", "دنیا", "سینا",
];
function rid(prefix = "id"): string {
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
}
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function todayStr(): string {
return new Date().toISOString().slice(0, 10);
}
function isBrowser(): boolean {
return typeof window !== "undefined";
}
const LS = {
session: "hokm.session",
profile: "hokm.profile",
daily: "hokm.daily",
};
function load<T>(key: string): T | null {
if (!isBrowser()) return null;
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function save(key: string, value: unknown): void {
if (!isBrowser()) return;
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* ignore */
}
}
function defaultProfile(session: AuthSession): UserProfile {
return {
id: session.userId,
username: "player_" + session.userId.slice(-4),
displayName: "بازیکن",
avatar: AVATARS[0].id,
phone: session.method === "phone" ? undefined : undefined,
level: 1,
xp: 0,
coins: 1000,
rating: 1000,
stats: {
games: 0,
wins: 0,
losses: 0,
kotsFor: 0,
kotsAgainst: 0,
tricks: 0,
bestWinStreak: 0,
currentWinStreak: 0,
},
ownedAvatars: [AVATARS[0].id, AVATARS[1].id],
ownedThemes: ["royal"],
achievements: {},
unlocked: [],
createdAt: Date.now(),
};
}
function makeFriend(status?: PresenceStatus): Friend {
return {
id: rid("fr"),
username: "u" + randInt(1000, 9999),
displayName: pick(PERSIAN_NAMES),
avatar: pick(AVATARS).id,
level: randInt(1, 40),
rating: randInt(900, 1800),
status: status ?? pick<PresenceStatus>(["online", "offline", "in-game", "online"]),
};
}
export class MockOnlineService implements OnlineService {
private session: AuthSession | null = null;
private profile: UserProfile | null = null;
private friends: Friend[] = [];
private requests: FriendRequest[] = [];
private room: Room | null = null;
private matchmaking: MatchmakingState = {
phase: "idle",
players: [],
elapsedMs: 0,
ranked: true,
stake: 0,
};
private matchPlayers:
| { id: string; displayName: string; avatar: string; level: number }[]
| null = null;
private currentOppRating = 1000;
private lastOtp = "";
private roomCbs = new Set<(r: Room) => void>();
private mmCbs = new Set<(s: MatchmakingState) => void>();
private friendCbs = new Set<(f: Friend[]) => void>();
private timers: ReturnType<typeof setTimeout>[] = [];
constructor() {
this.session = load<AuthSession>(LS.session);
this.profile = load<UserProfile>(LS.profile);
this.seedFriends();
}
private seedFriends() {
this.friends = Array.from({ length: 8 }, () => makeFriend());
// one pending request
this.requests = [{ id: rid("req"), from: makeFriend("online"), createdAt: Date.now() }];
}
private emitRoom() {
if (this.room) for (const cb of this.roomCbs) cb(this.room);
}
private emitMM() {
for (const cb of this.mmCbs) cb({ ...this.matchmaking });
}
private emitFriends() {
for (const cb of this.friendCbs) cb([...this.friends]);
}
private after(ms: number, fn: () => void) {
const t = setTimeout(fn, ms);
this.timers.push(t);
return t;
}
private saveProfile() {
if (this.profile) save(LS.profile, this.profile);
}
/* ------------------------------ auth ------------------------------- */
getSession() {
return this.session;
}
async restore() {
if (this.session && this.profile) {
return { session: this.session, profile: this.profile };
}
return null;
}
private establish(session: AuthSession): AuthSession {
this.session = session;
save(LS.session, session);
if (!this.profile) {
this.profile = defaultProfile(session);
this.saveProfile();
}
return session;
}
async requestOtp(phone: string) {
this.lastOtp = String(randInt(1000, 9999));
void phone;
// In dev we surface the code so it can be entered without a real SMS.
return { devCode: this.lastOtp };
}
async verifyOtp(phone: string, code: string) {
if (code !== this.lastOtp && code !== "1234") {
throw new Error("INVALID_CODE");
}
const session: AuthSession = {
userId: rid("user"),
token: rid("tok"),
method: "phone",
createdAt: Date.now(),
};
const s = this.establish(session);
if (this.profile && !this.profile.phone) {
this.profile.phone = phone;
this.saveProfile();
}
return s;
}
async signInEmail(email: string, password: string) {
void password;
const session: AuthSession = {
userId: rid("user"),
token: rid("tok"),
method: "email",
createdAt: Date.now(),
};
const s = this.establish(session);
if (this.profile && !this.profile.email) {
this.profile.email = email;
this.saveProfile();
}
return s;
}
async signUpEmail(email: string, password: string, displayName: string) {
const s = await this.signInEmail(email, password);
if (this.profile) {
this.profile.email = email;
if (displayName.trim()) this.profile.displayName = displayName.trim();
this.saveProfile();
}
return s;
}
async signInGoogle() {
const session: AuthSession = {
userId: rid("user"),
token: rid("tok"),
method: "google",
createdAt: Date.now(),
};
return this.establish(session);
}
async signOut() {
this.session = null;
if (isBrowser()) localStorage.removeItem(LS.session);
// keep profile so progress persists across sign-ins on the same device
}
/* ----------------------------- profile ----------------------------- */
async getProfile() {
if (!this.profile) {
// guest fallback profile (not persisted as session)
this.profile =
load<UserProfile>(LS.profile) ??
defaultProfile({
userId: rid("guest"),
token: "",
method: "guest",
createdAt: Date.now(),
});
this.saveProfile();
}
return this.profile;
}
async updateProfile(patch: Partial<Pick<UserProfile, "displayName" | "avatar">>) {
const p = await this.getProfile();
this.profile = { ...p, ...patch };
this.saveProfile();
return this.profile;
}
/* ----------------------------- friends ----------------------------- */
async listFriends() {
return [...this.friends];
}
async listRequests() {
return [...this.requests];
}
async addFriend(query: string) {
if (!query.trim()) {
return { ok: false, messageFa: "نام یا شماره را وارد کنید", messageEn: "Enter a name or number" };
}
const f = makeFriend("offline");
f.displayName = query.trim().startsWith("0") ? pick(PERSIAN_NAMES) : query.trim();
this.friends = [f, ...this.friends];
this.emitFriends();
return { ok: true, messageFa: "درخواست دوستی ارسال شد", messageEn: "Friend request sent" };
}
async acceptRequest(id: string) {
const req = this.requests.find((r) => r.id === id);
if (req) {
this.friends = [{ ...req.from, status: "online" }, ...this.friends];
this.requests = this.requests.filter((r) => r.id !== id);
this.emitFriends();
}
}
async declineRequest(id: string) {
this.requests = this.requests.filter((r) => r.id !== id);
}
async removeFriend(id: string) {
this.friends = this.friends.filter((f) => f.id !== id);
this.emitFriends();
}
onFriends(cb: (f: Friend[]) => void): Unsubscribe {
this.friendCbs.add(cb);
return () => this.friendCbs.delete(cb);
}
/* ------------------------------ rooms ------------------------------ */
private seatYou(): RoomSeat {
const p = this.profile!;
return {
seat: 0,
kind: "you",
player: { id: p.id, displayName: p.displayName, avatar: p.avatar, level: p.level },
};
}
async createRoom(opts: CreateRoomOptions) {
await this.getProfile();
this.room = {
id: rid("room"),
code: Math.random().toString(36).slice(2, 8).toUpperCase(),
hostId: this.profile!.id,
status: "open",
seats: [
this.seatYou(),
{ seat: 1, kind: "empty" },
{ seat: 2, kind: "empty" },
{ seat: 3, kind: "empty" },
],
targetScore: opts.targetScore,
stake: opts.stake,
ranked: opts.ranked,
};
return this.room;
}
private setSeat(seat: number, s: RoomSeat) {
if (!this.room) return;
this.room.seats = this.room.seats.map((x) => (x.seat === seat ? s : x));
}
private friendSeat(seat: 1 | 2 | 3, friendId: string, invited: boolean): RoomSeat {
const f = this.friends.find((x) => x.id === friendId);
return {
seat,
kind: invited ? "invited" : "friend",
player: f
? { id: f.id, displayName: f.displayName, avatar: f.avatar, level: f.level }
: { id: friendId, displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 30) },
};
}
async setPartner(roomId: string, friendId: string | null) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
if (friendId == null) {
this.setSeat(2, { seat: 2, kind: "empty" });
} else {
this.setSeat(2, this.friendSeat(2, friendId, true));
this.after(1100, () => {
this.setSeat(2, this.friendSeat(2, friendId, false));
this.emitRoom();
});
}
this.emitRoom();
return this.room;
}
async inviteToSeat(roomId: string, seat: 1 | 3, friendId: string) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, this.friendSeat(seat, friendId, true));
this.after(1100, () => {
this.setSeat(seat, this.friendSeat(seat, friendId, false));
this.emitRoom();
});
this.emitRoom();
return this.room;
}
async addBot(roomId: string, seat: 1 | 2 | 3) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, {
seat,
kind: "bot",
player: { id: rid("bot"), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 50) },
});
this.emitRoom();
return this.room;
}
async clearSeat(roomId: string, seat: 1 | 2 | 3) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, { seat, kind: "empty" });
this.emitRoom();
return this.room;
}
async startRoom(roomId: string) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
// fill empty seats with bots
for (const s of this.room.seats) {
if (s.kind === "empty" || s.kind === "invited") {
await this.addBot(roomId, s.seat as 1 | 2 | 3);
}
}
this.room.status = "in-game";
this.matchPlayers = this.room.seats
.slice()
.sort((a, b) => a.seat - b.seat)
.map((s) => s.player!) as typeof this.matchPlayers;
this.currentOppRating = this.profile?.rating ?? 1000;
this.emitRoom();
return this.room;
}
async leaveRoom(roomId: string) {
void roomId;
this.room = null;
}
onRoom(cb: (r: Room) => void): Unsubscribe {
this.roomCbs.add(cb);
return () => this.roomCbs.delete(cb);
}
/* --------------------------- matchmaking --------------------------- */
async startMatchmaking(opts: MatchmakingOptions) {
await this.getProfile();
const me = this.profile!;
this.matchmaking = {
phase: "searching",
players: [{ id: me.id, displayName: me.displayName, avatar: me.avatar, level: me.level, rating: me.rating }],
elapsedMs: 0,
ranked: opts.ranked,
stake: opts.stake,
};
this.emitMM();
const reveal = (delay: number) =>
this.after(delay, () => {
if (this.matchmaking.phase !== "searching") return;
this.matchmaking.players.push({
id: rid("p"),
displayName: pick(PERSIAN_NAMES),
avatar: pick(AVATARS).id,
level: randInt(1, 50),
rating: me.rating + randInt(-150, 150),
});
this.emitMM();
});
reveal(900);
reveal(1900);
reveal(2900);
this.after(3500, () => {
if (this.matchmaking.phase !== "searching") return;
this.matchmaking.phase = "found";
this.emitMM();
this.after(1200, () => {
if (this.matchmaking.phase !== "found") return;
this.matchmaking.phase = "ready";
// seat order: you=0, then revealed players
const players = this.matchmaking.players;
this.matchPlayers = players.map((p) => ({
id: p.id,
displayName: p.displayName,
avatar: p.avatar,
level: p.level,
}));
const opps = players.slice(1);
this.currentOppRating =
opps.reduce((s, p) => s + p.rating, 0) / Math.max(1, opps.length);
this.emitMM();
});
});
}
async cancelMatchmaking() {
this.matchmaking = { phase: "cancelled", players: [], elapsedMs: 0, ranked: true, stake: 0 };
this.emitMM();
this.matchmaking.phase = "idle";
}
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
this.mmCbs.add(cb);
return () => this.mmCbs.delete(cb);
}
/* ----------------------------- match ------------------------------- */
getMatchPlayers() {
return this.matchPlayers;
}
async submitMatchResult(summary: MatchSummary): Promise<RewardResult> {
const p = await this.getProfile();
const { profile, reward } = applyMatchResult(p, summary, this.currentOppRating);
this.profile = profile;
this.saveProfile();
if (this.room) this.room = null;
this.matchmaking.phase = "idle";
return reward;
}
/* --------------------- leaderboard / shop / daily ------------------ */
async getLeaderboard(): Promise<LeaderboardEntry[]> {
const p = await this.getProfile();
const others = Array.from({ length: 24 }, () => ({
id: rid("lb"),
displayName: pick(PERSIAN_NAMES),
avatar: pick(AVATARS).id,
level: randInt(5, 60),
rating: randInt(1000, 2200),
isYou: false,
}));
const you = {
id: p.id,
displayName: p.displayName,
avatar: p.avatar,
level: p.level,
rating: p.rating,
isYou: true,
};
const all = [...others, you].sort((a, b) => b.rating - a.rating);
return all.map((e, i) => ({ rank: i + 1, ...e }));
}
async getShopItems(): Promise<ShopItem[]> {
const avatarItems: ShopItem[] = AVATARS.slice(2).map((a, i) => ({
id: a.id,
kind: "avatar",
nameFa: "آواتار",
nameEn: "Avatar",
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];
}
async buyItem(id: string) {
const p = await this.getProfile();
const items = await this.getShopItems();
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);
if (owned) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" };
if (p.coins < item.price)
return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" };
this.profile = {
...p,
coins: p.coins - item.price,
ownedAvatars: item.kind === "avatar" ? [...p.ownedAvatars, id] : p.ownedAvatars,
ownedThemes: item.kind === "theme" ? [...p.ownedThemes, id] : p.ownedThemes,
};
this.saveProfile();
return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" };
}
async getDailyState(): Promise<DailyRewardState> {
const d = load<DailyRewardState>(LS.daily) ?? { day: 1, lastClaimed: null, available: true };
d.available = d.lastClaimed !== todayStr();
return d;
}
async claimDaily() {
const p = await this.getProfile();
const d = await this.getDailyState();
if (!d.available) return { reward: 0, profile: p, day: d.day };
const reward = dailyRewardFor(d.day);
this.profile = { ...p, coins: p.coins + reward };
this.saveProfile();
const nextDay = d.day >= 7 ? 1 : d.day + 1;
save(LS.daily, { day: nextDay, lastClaimed: todayStr(), available: false });
return { reward, profile: this.profile, day: d.day };
}
}
+93
View File
@@ -0,0 +1,93 @@
// The single seam between the UI and any backend.
// The mock implements this today; a SignalR/.NET client implements it later
// without any UI changes.
import {
AuthSession,
DailyRewardState,
Friend,
FriendRequest,
LeaderboardEntry,
MatchSummary,
MatchmakingState,
RewardResult,
Room,
ShopItem,
UserProfile,
} from "./types";
export interface CreateRoomOptions {
targetScore: number;
stake: number;
ranked: boolean;
}
export interface MatchmakingOptions {
stake: number;
ranked: boolean;
}
export type Unsubscribe = () => void;
export interface OnlineService {
/* ----- auth ----- */
getSession(): AuthSession | null;
restore(): Promise<{ session: AuthSession; profile: UserProfile } | null>;
requestOtp(phone: string): Promise<{ devCode?: string }>;
verifyOtp(phone: string, code: string): Promise<AuthSession>;
signInEmail(email: string, password: string): Promise<AuthSession>;
signUpEmail(email: string, password: string, displayName: string): Promise<AuthSession>;
signInGoogle(): Promise<AuthSession>;
signOut(): Promise<void>;
/* ----- profile ----- */
getProfile(): Promise<UserProfile>;
updateProfile(patch: Partial<Pick<UserProfile, "displayName" | "avatar">>): Promise<UserProfile>;
/* ----- friends ----- */
listFriends(): Promise<Friend[]>;
listRequests(): Promise<FriendRequest[]>;
addFriend(query: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
acceptRequest(id: string): Promise<void>;
declineRequest(id: string): Promise<void>;
removeFriend(id: string): Promise<void>;
onFriends(cb: (friends: Friend[]) => void): Unsubscribe;
/* ----- rooms ----- */
createRoom(opts: CreateRoomOptions): Promise<Room>;
setPartner(roomId: string, friendId: string | null): Promise<Room>;
inviteToSeat(roomId: string, seat: 1 | 3, friendId: string): Promise<Room>;
addBot(roomId: string, seat: 1 | 2 | 3): Promise<Room>;
clearSeat(roomId: string, seat: 1 | 2 | 3): Promise<Room>;
startRoom(roomId: string): Promise<Room>;
leaveRoom(roomId: string): Promise<void>;
onRoom(cb: (room: Room) => void): Unsubscribe;
/* ----- matchmaking ----- */
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
cancelMatchmaking(): Promise<void>;
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
/* ----- match players (for the online game driver) ----- */
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
/* ----- leaderboard / shop / daily ----- */
getLeaderboard(): Promise<LeaderboardEntry[]>;
getShopItems(): Promise<ShopItem[]>;
buyItem(id: string): Promise<{ ok: boolean; profile?: UserProfile; messageFa: string; messageEn: string }>;
getDailyState(): Promise<DailyRewardState>;
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }>;
}
import { MockOnlineService } from "./mock-service";
let _service: OnlineService | null = null;
/** Lazily create the active service. Swap the implementation here later. */
export function getService(): OnlineService {
if (!_service) {
_service = new MockOnlineService();
}
return _service;
}
+265
View File
@@ -0,0 +1,265 @@
// Online / social / gamification domain types.
// These are transport-agnostic: the mock service and the future SignalR
// client both speak in these shapes.
import { Suit } from "../hokm/types";
/* ------------------------------- Auth -------------------------------- */
export type AuthMethod = "phone" | "email" | "google" | "guest";
export interface AuthSession {
userId: string;
token: string;
method: AuthMethod;
createdAt: number;
}
/* ------------------------------ Profile ------------------------------ */
export interface PlayerStats {
games: number;
wins: number;
losses: number;
kotsFor: number; // kots inflicted
kotsAgainst: number;
tricks: number;
bestWinStreak: number;
currentWinStreak: number;
}
export interface UserProfile {
id: string;
username: string;
displayName: string;
avatar: string; // avatar id (see AVATARS)
phone?: string;
email?: string;
level: number;
xp: number; // xp within the current level
coins: number;
rating: number; // competitive rating
stats: PlayerStats;
ownedAvatars: string[];
ownedThemes: string[];
achievements: Record<string, number>; // achievementId -> progress count
unlocked: string[]; // achievementId list already unlocked
createdAt: number;
}
/* ------------------------------- Ranks ------------------------------- */
export type RankTierId =
| "bronze"
| "silver"
| "gold"
| "platinum"
| "diamond"
| "master";
export interface RankTier {
id: RankTierId;
nameFa: string;
nameEn: string;
/** inclusive rating floor for this tier */
floor: number;
color: string; // hex for badge
}
export interface LeagueInfo {
tier: RankTier;
/** division 1 (highest) .. 3 (lowest); master has no divisions */
division: number | null;
rating: number;
/** rating at which the player promotes to the next division/tier */
nextThreshold: number | null;
/** progress 0..1 toward nextThreshold within the current band */
progress: number;
}
/* --------------------------- Achievements ---------------------------- */
export interface AchievementDef {
id: string;
nameFa: string;
nameEn: string;
descFa: string;
descEn: string;
icon: string; // emoji or lucide name
goal: number; // progress needed to unlock
coinReward: number;
}
export interface AchievementView extends AchievementDef {
progress: number;
unlocked: boolean;
}
/* ------------------------------ Friends ------------------------------ */
export type PresenceStatus = "online" | "offline" | "in-game";
export interface Friend {
id: string;
username: string;
displayName: string;
avatar: string;
level: number;
rating: number;
status: PresenceStatus;
}
export interface FriendRequest {
id: string;
from: Friend;
createdAt: number;
}
/* ------------------------------- Rooms ------------------------------- */
export type RoomStatus = "open" | "starting" | "in-game" | "closed";
export type SeatOccupantKind = "you" | "friend" | "bot" | "empty" | "invited";
export interface RoomSeat {
seat: 0 | 1 | 2 | 3;
kind: SeatOccupantKind;
/** present for you/friend/bot/invited */
player?: {
id: string;
displayName: string;
avatar: string;
level: number;
};
}
export interface Room {
id: string;
code: string; // shareable join code
hostId: string;
status: RoomStatus;
/** seats[0] is always the host (you). seat 2 is the partner. */
seats: RoomSeat[];
targetScore: number;
stake: number; // coins
ranked: boolean;
}
/* --------------------------- Matchmaking ----------------------------- */
export type MatchmakingPhase =
| "idle"
| "searching"
| "found"
| "ready"
| "cancelled";
export interface MatchmakingState {
phase: MatchmakingPhase;
/** players revealed so far (incl. you), index = seat */
players: {
id: string;
displayName: string;
avatar: string;
level: number;
rating: number;
}[];
elapsedMs: number;
ranked: boolean;
stake: number;
}
/* ------------------------- Match + Rewards --------------------------- */
export interface MatchSummary {
ranked: boolean;
stake: number;
won: boolean;
kotFor: boolean;
kotAgainst: boolean;
tricksWon: number; // your team's total tricks across the match
rounds: number;
trump: Suit | null;
}
export interface AchievementUnlock {
id: string;
nameFa: string;
nameEn: string;
icon: string;
coinReward: number;
}
export interface RewardResult {
ratingBefore: number;
ratingAfter: number;
ratingDelta: number;
coinsBefore: number;
coinsAfter: number;
coinsDelta: number;
xpGained: number;
levelBefore: number;
levelAfter: number;
leveledUp: boolean;
newAchievements: AchievementUnlock[];
promoted: boolean;
demoted: boolean;
}
/* ---------------------------- Leaderboard ---------------------------- */
export interface LeaderboardEntry {
rank: number;
id: string;
displayName: string;
avatar: string;
level: number;
rating: number;
isYou: boolean;
}
/* ------------------------------- Shop -------------------------------- */
export type ShopItemKind = "avatar" | "theme";
export interface ShopItem {
id: string;
kind: ShopItemKind;
nameFa: string;
nameEn: string;
price: number;
preview: string; // emoji/avatar id/color
}
/* --------------------------- Daily reward ---------------------------- */
export interface DailyRewardState {
/** day index 1..7 the player is currently on */
day: number;
/** ISO date (yyyy-mm-dd) the reward was last claimed */
lastClaimed: string | null;
/** whether today's reward is available to claim */
available: boolean;
}
/* ------------------------------ Avatars ------------------------------ */
export const AVATARS: { id: string; emoji: string }[] = [
{ id: "a-fox", emoji: "🦊" },
{ id: "a-lion", emoji: "🦁" },
{ id: "a-owl", emoji: "🦉" },
{ id: "a-tiger", emoji: "🐯" },
{ id: "a-panda", emoji: "🐼" },
{ id: "a-eagle", emoji: "🦅" },
{ id: "a-wolf", emoji: "🐺" },
{ id: "a-cat", emoji: "🐱" },
{ id: "a-dragon", emoji: "🐲" },
{ id: "a-unicorn", emoji: "🦄" },
];
export function avatarEmoji(id: string): string {
return AVATARS.find((a) => a.id === id)?.emoji ?? "🦊";
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { create } from "zustand";
import { getService } from "./online/service";
import { AuthSession, UserProfile } from "./online/types";
interface SessionStore {
session: AuthSession | null;
profile: UserProfile | null;
loading: boolean;
isAuthed: boolean;
init: () => Promise<void>;
refreshProfile: () => Promise<void>;
setProfile: (p: UserProfile) => void;
requestOtp: (phone: string) => Promise<{ devCode?: string }>;
verifyOtp: (phone: string, code: string) => Promise<void>;
signInEmail: (email: string, password: string) => Promise<void>;
signUpEmail: (email: string, password: string, name: string) => Promise<void>;
signInGoogle: () => Promise<void>;
signOut: () => Promise<void>;
updateProfile: (patch: Partial<Pick<UserProfile, "displayName" | "avatar">>) => Promise<void>;
}
export const useSessionStore = create<SessionStore>((set, get) => ({
session: null,
profile: null,
loading: true,
isAuthed: false,
init: async () => {
const svc = getService();
const restored = await svc.restore();
if (restored) {
set({ session: restored.session, profile: restored.profile, isAuthed: true, loading: false });
} else {
// ensure a (guest) profile exists so the top bar can render
const profile = await svc.getProfile();
set({ profile, isAuthed: false, loading: false });
}
},
refreshProfile: async () => {
const profile = await getService().getProfile();
set({ profile });
},
setProfile: (p) => set({ profile: p }),
requestOtp: (phone) => getService().requestOtp(phone),
verifyOtp: async (phone, code) => {
const session = await getService().verifyOtp(phone, code);
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signInEmail: async (email, password) => {
const session = await getService().signInEmail(email, password);
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signUpEmail: async (email, password, name) => {
const session = await getService().signUpEmail(email, password, name);
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signInGoogle: async () => {
const session = await getService().signInGoogle();
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signOut: async () => {
await getService().signOut();
set({ session: null, isAuthed: false });
},
updateProfile: async (patch) => {
const profile = await getService().updateProfile(patch);
set({ profile });
},
}));
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { create } from "zustand";
export type Screen =
| "home"
| "auth"
| "profile"
| "friends"
| "online" // online lobby (create room / play random)
| "room"
| "matchmaking"
| "leaderboard"
| "shop"
| "game"; // the table (used for both ai + online)
interface UIStore {
screen: Screen;
/** screen to return to from the game table */
returnTo: Screen;
dailyModalOpen: boolean;
go: (screen: Screen) => void;
goGame: (returnTo?: Screen) => void;
openDaily: () => void;
closeDaily: () => void;
}
export const useUIStore = create<UIStore>((set) => ({
screen: "home",
returnTo: "home",
dailyModalOpen: false,
go: (screen) => set({ screen }),
goGame: (returnTo = "home") => set({ screen: "game", returnTo }),
openDaily: () => set({ dailyModalOpen: true }),
closeDaily: () => set({ dailyModalOpen: false }),
}));