diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..3d66cc1
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,12 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "hokm-dev",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev"],
+ "port": 3017,
+ "autoPort": true
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index de46b79..fb15c2d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,14 @@
"name": "hokm",
"version": "0.1.0",
"dependencies": {
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.40.0",
+ "lucide-react": "^1.17.0",
"next": "16.2.7",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "tailwind-merge": "^3.6.0",
+ "zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1579,7 +1584,7 @@
"version": "19.2.16",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2669,6 +2674,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2722,7 +2736,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -3660,6 +3674,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.40.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
+ "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.40.0",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4918,6 +4959,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
+ "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4985,6 +5035,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.40.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
+ "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6128,6 +6193,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+ "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -6646,6 +6721,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.14",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
+ "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 64a0696..4131d03 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,14 @@
"lint": "eslint"
},
"dependencies": {
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.40.0",
+ "lucide-react": "^1.17.0",
"next": "16.2.7",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "tailwind-merge": "^3.6.0",
+ "zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/public/icon.svg b/public/icon.svg
new file mode 100644
index 0000000..27f39c7
--- /dev/null
+++ b/public/icon.svg
@@ -0,0 +1,22 @@
+
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..def1c50
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,20 @@
+{
+ "name": "حکم | Hokm",
+ "short_name": "حکم",
+ "description": "بازی حکم اصیل ایرانی با حریفهای هوشمند",
+ "lang": "fa",
+ "dir": "rtl",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#060c1f",
+ "theme_color": "#060c1f",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/scripts/sim.ts b/scripts/sim.ts
new file mode 100644
index 0000000..07b8017
--- /dev/null
+++ b/scripts/sim.ts
@@ -0,0 +1,156 @@
+// Quick engine sanity sim: play full all-AI matches, assert no illegal states.
+import { chooseCardAI, chooseTrumpAI } from "../src/lib/hokm/ai";
+import {
+ advanceAfterTrick,
+ chooseTrump,
+ createInitialState,
+ dealForTrump,
+ playCard,
+ selectHakem,
+ startNextRound,
+} from "../src/lib/hokm/engine";
+import { GameState } from "../src/lib/hokm/types";
+import { applyMatchResult, getLeagueInfo } from "../src/lib/online/gamification";
+import { MatchSummary, UserProfile } from "../src/lib/online/types";
+
+function playMatch(seed: number): { rounds: number; tricks: number } {
+ let s = createInitialState({
+ names: ["P0", "P1", "P2", "P3"],
+ targetScore: 7,
+ });
+ s = selectHakem(s);
+ s = dealForTrump(s);
+ let rounds = 0;
+ let tricks = 0;
+ let guard = 0;
+
+ while (s.phase !== "match-over") {
+ guard++;
+ if (guard > 100000) throw new Error("loop guard tripped");
+
+ if (s.phase === "choosing-trump") {
+ const suit = chooseTrumpAI(s.players[s.hakem!].hand);
+ s = chooseTrump(s, suit);
+ continue;
+ }
+
+ if (s.phase === "playing") {
+ const seat = s.turn!;
+ const card = chooseCardAI(s, seat);
+ s = playCard(s, seat, card);
+ continue;
+ }
+
+ if (s.phase === "trick-complete") {
+ tricks++;
+ // sanity: 4 cards in trick
+ if (s.currentTrick.length !== 4) throw new Error("trick not full");
+ s = advanceAfterTrick(s, 2);
+ continue;
+ }
+
+ if (s.phase === "round-over") {
+ rounds++;
+ // sanity: total tricks this round <= 13
+ const total = s.roundTricks[0] + s.roundTricks[1];
+ if (total > 13) throw new Error("too many tricks: " + total);
+ s = startNextRound(s);
+ continue;
+ }
+
+ throw new Error("unexpected phase: " + s.phase);
+ }
+ return { rounds, tricks };
+}
+
+let totalRounds = 0;
+const N = 200;
+for (let i = 0; i < N; i++) {
+ const r = playMatch(i);
+ totalRounds += r.rounds;
+}
+console.log(`OK: ${N} matches completed. avg rounds/match = ${(totalRounds / N).toFixed(1)}`);
+
+/* ----------------------- gamification checks ----------------------- */
+
+function baseProfile(): UserProfile {
+ return {
+ id: "u",
+ username: "u",
+ displayName: "Tester",
+ avatar: "a-fox",
+ 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: ["a-fox"],
+ ownedThemes: ["royal"],
+ achievements: {},
+ unlocked: [],
+ createdAt: 0,
+ };
+}
+
+function assert(cond: boolean, msg: string) {
+ if (!cond) throw new Error("ASSERT FAILED: " + msg);
+}
+
+let profile = baseProfile();
+let firstWinSeen = false;
+const M = 500;
+for (let i = 0; i < M; i++) {
+ const won = (i * 7 + 3) % 5 < 3; // deterministic-ish mix
+ const kot = i % 6 === 0;
+ const summary: MatchSummary = {
+ ranked: true,
+ stake: 100,
+ won,
+ kotFor: won && kot,
+ kotAgainst: !won && kot,
+ tricksWon: won ? 7 + (i % 6) : i % 7,
+ rounds: 7,
+ trump: "spades",
+ };
+ const before = profile;
+ const { profile: after, reward } = applyMatchResult(before, summary, 1000);
+
+ // rating moves the right way for ranked
+ if (won) assert(reward.ratingDelta > 0, "ranked win should raise rating");
+ else assert(reward.ratingDelta < 0, "ranked loss should lower rating");
+ // coins never negative
+ assert(after.coins >= 0, "coins never negative");
+ // xp gained, level monotonic
+ assert(reward.xpGained > 0, "xp gained");
+ assert(after.level >= before.level, "level monotonic");
+ // unlocked list only grows
+ assert(after.unlocked.length >= before.unlocked.length, "achievements monotonic");
+ // first win unlocks first_win
+ if (won && !firstWinSeen) {
+ firstWinSeen = true;
+ assert(after.unlocked.includes("first_win"), "first_win unlocks on first win");
+ }
+ profile = after;
+}
+
+// casual match must not change rating
+{
+ const r = applyMatchResult(baseProfile(), {
+ ranked: false, stake: 0, won: true, kotFor: false, kotAgainst: false,
+ tricksWon: 7, rounds: 7, trump: null,
+ }, 1000);
+ assert(r.reward.ratingDelta === 0, "casual match must not change rating");
+}
+
+// league boundaries sane
+assert(getLeagueInfo(1000).tier.id === "bronze", "1000 = bronze");
+assert(getLeagueInfo(1350).tier.id === "gold", "1350 = gold");
+assert(getLeagueInfo(2000).tier.id === "master", "2000 = master");
+
+console.log(
+ `OK: gamification ${M} results. final level=${profile.level} rating=${Math.round(profile.rating)} ` +
+ `coins=${profile.coins} achievements=${profile.unlocked.length} league=${getLeagueInfo(profile.rating).tier.id}`
+);
diff --git a/src/app/globals.css b/src/app/globals.css
index a2dc41e..9c50a20 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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;
+ }
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 976eb90..ec084d2 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 (
-
{children}
+
+ {children}
+
);
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 3f36f7c..5c0b696 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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 (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
+ <>
+ {renderScreen(screen)}
+
+ {loading && null}
+ >
);
}
+
+function renderScreen(screen: string) {
+ switch (screen) {
+ case "game":
+ return ;
+ case "auth":
+ return ;
+ case "profile":
+ return ;
+ case "friends":
+ return ;
+ case "online":
+ return ;
+ case "room":
+ return ;
+ case "matchmaking":
+ return ;
+ case "leaderboard":
+ return ;
+ case "shop":
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx
new file mode 100644
index 0000000..187be79
--- /dev/null
+++ b/src/components/GameTable.tsx
@@ -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 (
+
+ {/* Top HUD */}
+
+
+
+ {trump && }
+
+
+
+
+ {/* Felt table */}
+
+
+ {/* opponent + partner seats */}
+
+
+
+
+ {/* opponents' face-down hands */}
+
+
+
+
+ {/* center trick area */}
+
+
+
+
+ {/* Your hand */}
+
+
+ {/* Turn indicator */}
+
+
+ {/* Overlays */}
+
+ {phase === "selecting-hakem" && }
+ {phase === "choosing-trump" && players[hakem!]?.isHuman && (
+
+ )}
+ {phase === "round-over" && }
+ {phase === "match-over" && mode === "ai" && (
+
+ )}
+
+
+ );
+}
+
+/* ----------------------------- Scoreboard ----------------------------- */
+
+function Scoreboard() {
+ const game = useGameStore((s) => s.game);
+ const { t } = useI18n();
+ return (
+
+
+
/
+
+
+
{t("home.target")}
+
{game.targetScore}
+
+
+ );
+}
+
+function ScoreCol({
+ label,
+ tricks,
+ score,
+ accent,
+}: {
+ label: string;
+ tricks: number;
+ score: number;
+ accent: string;
+}) {
+ const { t } = useI18n();
+ return (
+
+
{label}
+
{score}
+
+ {t("score.tricks")}: {tricks}
+
+
+ );
+}
+
+/* ----------------------------- Trump badge ---------------------------- */
+
+function TrumpBadge({ trump }: { trump: Suit }) {
+ const { t } = useI18n();
+ const red = SUIT_IS_RED[trump];
+ return (
+
+
+ {t("trump.label")}
+
+
+ {SUIT_SYMBOL[trump]}
+
+
+ );
+}
+
+/* ----------------------------- 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 (
+
+
+ {sp?.avatar ?? name.charAt(0)}
+ {isHakem && (
+
+ )}
+
+ {name}
+ {sp && sp.level > 0 && (
+
+ {team === 0 ? "" : ""}
+ {`Lv ${sp.level}`}
+
+ )}
+
+ );
+}
+
+/* --------------------------- 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 (
+
+ {cards.map((_, i) => (
+
+ ))}
+
+ );
+}
+
+/* ----------------------------- Trick area ----------------------------- */
+
+const TRICK_OFFSET: Record = {
+ 0: { x: 0, y: 70 },
+ 1: { x: 96, y: 0 },
+ 2: { x: 0, y: -70 },
+ 3: { x: -96, y: 0 },
+};
+const TRICK_ENTER: Record = {
+ 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 (
+
+
+
+ {trick.map((pc) => {
+ const off = TRICK_OFFSET[pc.seat];
+ const enter = TRICK_ENTER[pc.seat];
+ const isWinner =
+ phase === "trick-complete" && winner === pc.seat;
+ return (
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+/* ----------------------------- Player hand ---------------------------- */
+
+function PlayerHand({ legalIds }: { legalIds: Set }) {
+ 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 (
+
+
+ {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 (
+
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"
+ )}
+ >
+
+
+ );
+ })}
+
+
+ );
+}
+
+/* --------------------------- 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 (
+
+
+
+ {isYou ? t("turn.you") : t("turn.other", { name })}
+
+
+
+ );
+}
+
+/* ------------------------------ Overlays ------------------------------ */
+
+function Backdrop({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function HakemOverlay() {
+ const game = useGameStore((s) => s.game);
+ const { t } = useI18n();
+ const hakemName = game.hakem != null ? game.players[game.hakem].name : "";
+ return (
+
+
+ {t("hakem.title")}
+ {t("hakem.desc")}
+
+ {game.hakemDraw.map((pc, i) => (
+
+
+
+ ))}
+
+
+
+ {t("hakem.is", { name: hakemName })}
+
+
+
+ );
+}
+
+function TrumpChooser() {
+ const choose = useGameStore((s) => s.chooseTrump);
+ const { t } = useI18n();
+ return (
+
+
+ {t("trump.title")}
+ {t("trump.desc")}
+
+ {SUITS.map((suit) => {
+ const red = SUIT_IS_RED[suit];
+ return (
+ choose(suit)}
+ className="rounded-2xl bg-navy-900/80 gold-border py-6 flex items-center justify-center hover:bg-navy-800 transition"
+ >
+
+ {SUIT_SYMBOL[suit]}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+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 (
+
+
+ {t("round.over")}
+ {r.kot && (
+
+ {t("round.kot")}🔥
+
+ )}
+
+ {t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
+
+
+ {t("round.score", {
+ us: game.matchScore[0],
+ them: game.matchScore[1],
+ })}
+
+
+ {t("round.next")}
+
+
+
+ );
+}
+
+function MatchOverlay({ onExit }: { onExit: () => void }) {
+ const game = useGameStore((s) => s.game);
+ const { t } = useI18n();
+ const youWin = game.matchWinner === 0;
+ return (
+
+
+
+ {youWin ? "🏆" : "🎴"}
+
+ {t("match.over")}
+
+ {youWin ? t("match.youWin") : t("match.youLose")}
+
+
+ {t("round.score", {
+ us: game.matchScore[0],
+ them: game.matchScore[1],
+ })}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/HomeScreen.tsx b/src/components/HomeScreen.tsx
new file mode 100644
index 0000000..cd9283f
--- /dev/null
+++ b/src/components/HomeScreen.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {/* logo */}
+
+
+ ♠
+
+
+ {t("app.title")}
+
+ {t("app.subtitle")}
+
+
+ {/* primary actions */}
+
+
}
+ title={t("menu.online")}
+ desc={t("menu.onlineDesc")}
+ onClick={playOnline}
+ primary
+ />
+
}
+ title={t("menu.vsComputer")}
+ desc={t("menu.vsComputerDesc")}
+ onClick={playVsComputer}
+ />
+
+
+ {/* tiles */}
+
+ } label={t("menu.profile")} onClick={() => go("profile")} />
+ } label={t("menu.friends")} onClick={() => go(isAuthed ? "friends" : "auth")} />
+ } label={t("menu.leaderboard")} onClick={() => go("leaderboard")} />
+ } label={t("menu.shop")} onClick={() => go("shop")} />
+
+
+
+
+ {/* footer */}
+
+ {isAuthed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+function PrimaryCard({
+ icon,
+ title,
+ desc,
+ onClick,
+ primary,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ desc: string;
+ onClick: () => void;
+ primary?: boolean;
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {desc}
+
+
+
+ );
+}
+
+function Tile({
+ icon,
+ label,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
+
+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 (
+
+ {items.map((it, i) => (
+
+ {it.s}
+
+ ))}
+
+ );
+}
diff --git a/src/components/PlayingCard.tsx b/src/components/PlayingCard.tsx
new file mode 100644
index 0000000..e601410
--- /dev/null
+++ b/src/components/PlayingCard.tsx
@@ -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 (
+
+ );
+ }
+
+ const red = SUIT_IS_RED[card.suit];
+ const color = red ? "text-rose-600" : "text-slate-900";
+ const symbol = SUIT_SYMBOL[card.suit];
+
+ return (
+
+
+
{rankLabel(card.rank)}
+
{symbol}
+
+
+ {symbol}
+
+
+
{rankLabel(card.rank)}
+
{symbol}
+
+
+ );
+}
diff --git a/src/components/online/DailyRewardModal.tsx b/src/components/online/DailyRewardModal.tsx
new file mode 100644
index 0000000..d498296
--- /dev/null
+++ b/src/components/online/DailyRewardModal.tsx
@@ -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(null);
+ const [claimed, setClaimed] = useState(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 (
+
+ {open && (
+
+ e.stopPropagation()}
+ className="glass rounded-3xl p-7 w-full max-w-sm text-center"
+ >
+ {t("daily.title")}
+
+
+ {DAILY_REWARDS.map((coins, i) => {
+ const day = i + 1;
+ const isToday = state?.day === day && state?.available;
+ const isPast = state ? day < state.day : false;
+ return (
+
+
+ {t("daily.day", { n: day })}
+
+
+ {coins}
+
+
+
+ );
+ })}
+
+
+ {claimed != null ? (
+
+ +{claimed} {t("daily.claimed")}
+
+ ) : state?.available ? (
+
+ ) : (
+ {t("daily.come")}
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/online/PostMatchRewardsModal.tsx b/src/components/online/PostMatchRewardsModal.tsx
new file mode 100644
index 0000000..f9e6128
--- /dev/null
+++ b/src/components/online/PostMatchRewardsModal.tsx
@@ -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 (
+
+
+
+ {won ? "🏆" : "🎴"}
+
+ {t("reward.title")}
+
+ {won ? t("reward.win") : t("reward.lose")}
+
+
+
+ {reward.ratingDelta !== 0 && (
+ 0 ? (
+
+ ) : (
+
+ )
+ }
+ label={t("reward.rating")}
+ value={sign(reward.ratingDelta)}
+ positive={reward.ratingDelta > 0}
+ delay={0.2}
+ />
+ )}
+ }
+ label={t("reward.coins")}
+ value={sign(reward.coinsDelta)}
+ positive={reward.coinsDelta >= 0}
+ delay={0.3}
+ />
+ }
+ label={t("reward.xp")}
+ value={`+${reward.xpGained}`}
+ positive
+ delay={0.4}
+ />
+
+
+ {reward.leveledUp && (
+
+ )}
+ {reward.promoted && }
+
+ {reward.newAchievements.length > 0 && (
+
+ {reward.newAchievements.map((a, i) => (
+
+ {a.icon}
+
+
+ {t("reward.newAchievement")}
+
+
+ {locale === "fa" ? a.nameFa : a.nameEn}
+
+
+
+ +{a.coinReward}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+function RewardRow({
+ icon,
+ label,
+ value,
+ positive,
+ delay,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+ positive: boolean;
+ delay: number;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function Banner({ text, delay }: { text: string; delay: number }) {
+ return (
+
+
+ {text}
+
+ );
+}
diff --git a/src/components/online/RankBadge.tsx b/src/components/online/RankBadge.tsx
new file mode 100644
index 0000000..0e4da63
--- /dev/null
+++ b/src/components/online/RankBadge.tsx
@@ -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 (
+
+
+ {name}
+ {div && {div}}
+ {showRating && · {Math.round(rating)}}
+
+ );
+}
diff --git a/src/components/online/ScreenHeader.tsx b/src/components/online/ScreenHeader.tsx
new file mode 100644
index 0000000..ca1d906
--- /dev/null
+++ b/src/components/online/ScreenHeader.tsx
@@ -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 (
+
+
+
{title}
+
{right}
+
+ );
+}
+
+export function ScreenShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/online/TopBar.tsx b/src/components/online/TopBar.tsx
new file mode 100644
index 0000000..77f5286
--- /dev/null
+++ b/src/components/online/TopBar.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {profile.coins.toLocaleString()}
+
+
+
+
+ );
+}
diff --git a/src/components/online/XpBar.tsx b/src/components/online/XpBar.tsx
new file mode 100644
index 0000000..5713cf8
--- /dev/null
+++ b/src/components/online/XpBar.tsx
@@ -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 (
+
+
+
+ {t("common.level")} {level}
+
+
+ {xp} / {need} XP
+
+
+
+
+ );
+}
diff --git a/src/components/screens/AuthScreen.tsx b/src/components/screens/AuthScreen.tsx
new file mode 100644
index 0000000..ac2c060
--- /dev/null
+++ b/src/components/screens/AuthScreen.tsx
@@ -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("phone");
+
+ const done = () => go("online");
+
+ return (
+
+
+
+
{t("auth.subtitle")}
+
+
+
setTab("phone")} icon={} label={t("auth.phone")} />
+ setTab("email")} icon={} label={t("auth.email")} />
+
+
+ {tab === "phone" ?
:
}
+
+
+
+
+
+
+ );
+}
+
+function TabBtn({
+ active,
+ onClick,
+ icon,
+ label,
+}: {
+ active: boolean;
+ onClick: () => void;
+ icon: React.ReactNode;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+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(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 (
+
+
+
+ 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"
+ />
+
+
+ {devCode == null ? (
+
+ ) : (
+
+
+ {t("auth.devCode", { code: devCode })}
+
+
+
+ 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"
+ />
+
+ {error && {error}
}
+
+
+ )}
+
+ );
+}
+
+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 (
+
+ {mode === "up" && (
+
+
+ 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"
+ />
+
+ )}
+
+
+ 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"
+ />
+
+
+
+ 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"
+ />
+
+
+
+
+ );
+}
+
+function GoogleIcon() {
+ return (
+
+ );
+}
diff --git a/src/components/screens/FriendsScreen.tsx b/src/components/screens/FriendsScreen.tsx
new file mode 100644
index 0000000..6900777
--- /dev/null
+++ b/src/components/screens/FriendsScreen.tsx
@@ -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 = {
+ 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 (
+
+
+
+ {/* add */}
+
+ 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"
+ />
+
+
+
+ {/* requests */}
+ {requests.length > 0 && (
+
+
{t("friends.requests")}
+
+ {requests.map((r) => (
+
+ {avatarEmoji(r.from.avatar)}
+
+ {r.from.displayName}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* list */}
+
+ {friends.length === 0 && (
+
{t("friends.empty")}
+ )}
+ {friends.map((f: Friend) => (
+
+
+ {avatarEmoji(f.avatar)}
+
+
+
+
{f.displayName}
+
+ {statusLabel(f.status)} · {t("common.level")} {f.level}
+
+
+
{Math.round(f.rating)}
+
+
+ ))}
+
+ {locale}
+
+ );
+}
diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx
new file mode 100644
index 0000000..09b78fa
--- /dev/null
+++ b/src/components/screens/GameScreen.tsx
@@ -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(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 (
+ <>
+
+ {reward && (
+ {
+ setReward(null);
+ exit();
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/components/screens/LeaderboardScreen.tsx b/src/components/screens/LeaderboardScreen.tsx
new file mode 100644
index 0000000..352405c
--- /dev/null
+++ b/src/components/screens/LeaderboardScreen.tsx
@@ -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 = { 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 (
+
+
+
+ {leaderboard.map((e) => (
+
+
+ {MEDALS[e.rank] ?? e.rank}
+
+
{avatarEmoji(e.avatar)}
+
+
+ {e.displayName}
+ {e.isYou && ({t("seat.you")})}
+
+
+ {t("common.level")} {e.level}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/screens/MatchmakingScreen.tsx b/src/components/screens/MatchmakingScreen.tsx
new file mode 100644
index 0000000..18d8f9a
--- /dev/null
+++ b/src/components/screens/MatchmakingScreen.tsx
@@ -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 (
+
+
+
+ {ready ? (
+ ✅
+ ) : (
+
+ )}
+
+
+
+ {ready ? t("mm.ready") : mm.phase === "found" ? t("mm.found") : t("mm.searching")}
+
+
+
+ {slots.map((i) => {
+ const p = mm.players[i];
+ return (
+
+
+ {p ? (
+
+ {avatarEmoji(p.avatar)}
+
+ {p.displayName}
+
+
+ {t("common.level")} {p.level}
+
+
+ ) : (
+
+ ?
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ {ready && (
+
+ {t("mm.start")}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/screens/OnlineLobbyScreen.tsx b/src/components/screens/OnlineLobbyScreen.tsx
new file mode 100644
index 0000000..e537a9e
--- /dev/null
+++ b/src/components/screens/OnlineLobbyScreen.tsx
@@ -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 (
+
+
+
+ {/* stake */}
+
+
+
+ {t("room.stake")}
+
+
+ {STAKES.map((s) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {t("lobby.random")}
+ {t("lobby.randomDesc")}
+
+
+
+
+
+
+
+
+ {t("lobby.createRoom")}
+ {t("lobby.createDesc")}
+
+
+
+
+ );
+}
diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx
new file mode 100644
index 0000000..abaac61
--- /dev/null
+++ b/src/components/screens/ProfileScreen.tsx
@@ -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 (
+
+
+
+ {/* identity */}
+
+
+ {avatarEmoji(profile.avatar)}
+
+
+ {editing ? (
+
+ 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"
+ />
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {profile.coins.toLocaleString()}
+
+
+
+
+
+
+
+
+ {/* avatar picker */}
+
+
{t("profile.chooseAvatar")}
+
+ {AVATARS.filter((a) => profile.ownedAvatars.includes(a.id)).map((a) => (
+
+ ))}
+
+
+
+ {/* stats */}
+
+
{t("profile.stats")}
+
+
+
+
+
+
+
+
+
+
+ {/* achievements */}
+
+
{t("profile.achievements")}
+
+ {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 (
+
+
+ {a.icon}
+
+
+
+ {locale === "fa" ? a.nameFa : a.nameEn}
+
+
+ {locale === "fa" ? a.descFa : a.descEn}
+
+ {!unlocked && a.goal > 1 && (
+
+ )}
+
+ {unlocked &&
}
+
+ );
+ })}
+
+
+
+ );
+}
+
+function Stat({ label, value }: { label: string; value: string | number }) {
+ return (
+
+ );
+}
diff --git a/src/components/screens/RoomScreen.tsx b/src/components/screens/RoomScreen.tsx
new file mode 100644
index 0000000..9883add
--- /dev/null
+++ b/src/components/screens/RoomScreen.tsx
@@ -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);
+ 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 (
+
+
+
+ {copied ? t("common.copied") : room.code}
+
+ }
+ />
+
+ {/* your team */}
+ {t("team.us")}
+
+ {}} onBot={() => {}} onClear={() => {}} />
+ setPicker({ seat: 2 })}
+ onBot={() => addBot(2)}
+ onClear={() => clearSeat(2)}
+ />
+
+
+ {/* opponents */}
+ {t("room.opponents")}
+
+ setPicker({ seat: 1 })}
+ onBot={() => addBot(1)}
+ onClear={() => clearSeat(1)}
+ />
+ setPicker({ seat: 3 })}
+ onBot={() => addBot(3)}
+ onClear={() => clearSeat(3)}
+ />
+
+
+
+
+
+
+
+ {/* friend picker */}
+
+ {picker && (
+ 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"
+ >
+ e.stopPropagation()}
+ className="glass rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto"
+ >
+ {t("room.pickFriend")}
+
+ {friends.map((f) => (
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
+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 (
+
+
{label}
+ {filled ? (
+ <>
+
{avatarEmoji(seat.player?.avatar ?? "a-fox")}
+
+ {seat.player?.displayName}
+ {seat.kind === "bot" && 🤖}
+
+ {seat.kind === "invited" ? (
+
{t("room.waiting")}
+ ) : (
+ role !== "you" && (
+
+ )
+ )}
+ >
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx
new file mode 100644
index 0000000..5c7315d
--- /dev/null
+++ b/src/components/screens/ShopScreen.tsx
@@ -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([]);
+ 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 (
+
+
+
+ {profile.coins.toLocaleString()}
+
+ }
+ />
+
+ {msg && (
+ {msg}
+ )}
+
+
+
+ {avatars.map((item) => (
+ buy(item)} preview={{item.preview}} />
+ ))}
+
+
+
+
+
+ {themes.map((item) => (
+ buy(item)}
+ preview={
+
+ }
+ />
+ ))}
+
+
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+function ItemCard({
+ item,
+ owned,
+ onBuy,
+ preview,
+}: {
+ item: ShopItem;
+ owned: boolean;
+ onBuy: () => void;
+ preview: React.ReactNode;
+}) {
+ const { t } = useI18n();
+ return (
+
+
{preview}
+
+
+ );
+}
diff --git a/src/lib/cn.ts b/src/lib/cn.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/src/lib/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts
new file mode 100644
index 0000000..28403b2
--- /dev/null
+++ b/src/lib/game-store.ts
@@ -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 | null = null;
+function clearPending() {
+ if (pending) {
+ clearTimeout(pending);
+ pending = null;
+ }
+}
+
+function freshTally(): MatchTally {
+ return { tricksTeam0: 0, kotFor: false, kotAgainst: false };
+}
+
+export const useGameStore = create((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(),
+ });
+ },
+ };
+});
diff --git a/src/lib/hokm/ai.ts b/src/lib/hokm/ai.ts
new file mode 100644
index 0000000..92fff83
--- /dev/null
+++ b/src/lib/hokm/ai.ts
@@ -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);
+}
diff --git a/src/lib/hokm/deck.ts b/src/lib/hokm/deck.ts
new file mode 100644
index 0000000..46febc6
--- /dev/null
+++ b/src/lib/hokm/deck.ts
@@ -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;
+}
+
+/** Fisher–Yates shuffle. Returns a new array. */
+export function shuffle(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;
+ });
+}
diff --git a/src/lib/hokm/engine.ts b/src/lib/hokm/engine.ts
new file mode 100644
index 0000000..45c9d20
--- /dev/null
+++ b/src/lib/hokm/engine.ts
@@ -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 };
diff --git a/src/lib/hokm/types.ts b/src/lib/hokm/types.ts
new file mode 100644
index 0000000..bb34972
--- /dev/null
+++ b/src/lib/hokm/types.ts
@@ -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 = {
+ spades: "♠",
+ hearts: "♥",
+ diamonds: "♦",
+ clubs: "♣",
+};
+
+export const SUIT_IS_RED: Record = {
+ 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;
+}
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
new file mode 100644
index 0000000..f6f7d1f
--- /dev/null
+++ b/src/lib/i18n.tsx
@@ -0,0 +1,435 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+
+export type Locale = "fa" | "en";
+
+type Dict = Record;
+
+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 = { fa, en };
+
+interface I18nValue {
+ locale: Locale;
+ dir: "rtl" | "ltr";
+ t: (key: string, vars?: Record) => string;
+ setLocale: (l: Locale) => void;
+ toggle: () => void;
+}
+
+const I18nContext = createContext(null);
+
+export function I18nProvider({ children }: { children: React.ReactNode }) {
+ const [locale, setLocaleState] = useState("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) => {
+ 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(
+ () => ({
+ locale,
+ dir,
+ t,
+ setLocale,
+ toggle: () => setLocale(locale === "fa" ? "en" : "fa"),
+ }),
+ [locale, dir, t, setLocale]
+ );
+
+ return {children};
+}
+
+export function useI18n(): I18nValue {
+ const ctx = useContext(I18nContext);
+ if (!ctx) throw new Error("useI18n must be used within I18nProvider");
+ return ctx;
+}
diff --git a/src/lib/online-store.ts b/src/lib/online-store.ts
new file mode 100644
index 0000000..c22fbde
--- /dev/null
+++ b/src/lib/online-store.ts
@@ -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;
+ addFriend: (q: string) => Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
+ acceptRequest: (id: string) => Promise;
+ declineRequest: (id: string) => Promise;
+ removeFriend: (id: string) => Promise;
+
+ createRoom: (opts: CreateRoomOptions) => Promise;
+ setPartner: (friendId: string | null) => Promise;
+ inviteToSeat: (seat: 1 | 3, friendId: string) => Promise;
+ addBot: (seat: 1 | 2 | 3) => Promise;
+ clearSeat: (seat: 1 | 2 | 3) => Promise;
+ startRoom: () => Promise;
+ leaveRoom: () => Promise;
+
+ startMatchmaking: (opts: MatchmakingOptions) => Promise;
+ cancelMatchmaking: () => Promise;
+
+ loadLeaderboard: () => Promise;
+}
+
+let roomUnsub: (() => void) | null = null;
+let mmUnsub: (() => void) | null = null;
+let friendUnsub: (() => void) | null = null;
+
+export const useOnlineStore = create((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 });
+ },
+}));
diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts
new file mode 100644
index 0000000..f18b095
--- /dev/null
+++ b/src/lib/online/gamification.ts
@@ -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;
+}
diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts
new file mode 100644
index 0000000..9d4654d
--- /dev/null
+++ b/src/lib/online/mock-service.ts
@@ -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(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(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(["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[] = [];
+
+ constructor() {
+ this.session = load(LS.session);
+ this.profile = load(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(LS.profile) ??
+ defaultProfile({
+ userId: rid("guest"),
+ token: "",
+ method: "guest",
+ createdAt: Date.now(),
+ });
+ this.saveProfile();
+ }
+ return this.profile;
+ }
+
+ async updateProfile(patch: Partial>) {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const d = load(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 };
+ }
+}
diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts
new file mode 100644
index 0000000..978377d
--- /dev/null
+++ b/src/lib/online/service.ts
@@ -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;
+ signInEmail(email: string, password: string): Promise;
+ signUpEmail(email: string, password: string, displayName: string): Promise;
+ signInGoogle(): Promise;
+ signOut(): Promise;
+
+ /* ----- profile ----- */
+ getProfile(): Promise;
+ updateProfile(patch: Partial>): Promise;
+
+ /* ----- friends ----- */
+ listFriends(): Promise;
+ listRequests(): Promise;
+ addFriend(query: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
+ acceptRequest(id: string): Promise;
+ declineRequest(id: string): Promise;
+ removeFriend(id: string): Promise;
+ onFriends(cb: (friends: Friend[]) => void): Unsubscribe;
+
+ /* ----- rooms ----- */
+ createRoom(opts: CreateRoomOptions): Promise;
+ setPartner(roomId: string, friendId: string | null): Promise;
+ inviteToSeat(roomId: string, seat: 1 | 3, friendId: string): Promise;
+ addBot(roomId: string, seat: 1 | 2 | 3): Promise;
+ clearSeat(roomId: string, seat: 1 | 2 | 3): Promise;
+ startRoom(roomId: string): Promise;
+ leaveRoom(roomId: string): Promise;
+ onRoom(cb: (room: Room) => void): Unsubscribe;
+
+ /* ----- matchmaking ----- */
+ startMatchmaking(opts: MatchmakingOptions): Promise;
+ cancelMatchmaking(): Promise;
+ 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;
+
+ /* ----- leaderboard / shop / daily ----- */
+ getLeaderboard(): Promise;
+ getShopItems(): Promise;
+ buyItem(id: string): Promise<{ ok: boolean; profile?: UserProfile; messageFa: string; messageEn: string }>;
+ getDailyState(): Promise;
+ 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;
+}
diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts
new file mode 100644
index 0000000..d0c3f60
--- /dev/null
+++ b/src/lib/online/types.ts
@@ -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; // 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 ?? "🦊";
+}
diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts
new file mode 100644
index 0000000..42b5294
--- /dev/null
+++ b/src/lib/session-store.ts
@@ -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;
+ refreshProfile: () => Promise;
+ setProfile: (p: UserProfile) => void;
+
+ requestOtp: (phone: string) => Promise<{ devCode?: string }>;
+ verifyOtp: (phone: string, code: string) => Promise;
+ signInEmail: (email: string, password: string) => Promise;
+ signUpEmail: (email: string, password: string, name: string) => Promise;
+ signInGoogle: () => Promise;
+ signOut: () => Promise;
+
+ updateProfile: (patch: Partial>) => Promise;
+}
+
+export const useSessionStore = create((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 });
+ },
+}));
diff --git a/src/lib/ui-store.ts b/src/lib/ui-store.ts
new file mode 100644
index 0000000..3fc4a60
--- /dev/null
+++ b/src/lib/ui-store.ts
@@ -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((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 }),
+}));