From e2d0a602b64b2faf8789c0f4ba561d836d07276f Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 10:11:00 +0330 Subject: [PATCH] Build Hokm card game: offline vs-AI + online social/gamification (mock backend) - Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots - Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand) - Online platform behind OnlineService seam (mock now, .NET SignalR later): auth (phone OTP + email/Google), profiles, friends, private rooms with partner pick, ranked matchmaking, leaderboard, shop - Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements - i18n fa/en, PWA manifest, engine + gamification sims Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 12 + package-lock.json | 110 +++- package.json | 7 +- public/icon.svg | 22 + public/manifest.webmanifest | 20 + scripts/sim.ts | 156 +++++ src/app/globals.css | 154 ++++- src/app/layout.tsx | 44 +- src/app/page.tsx | 116 ++-- src/components/GameTable.tsx | 572 +++++++++++++++++ src/components/HomeScreen.tsx | 214 ++++++ src/components/PlayingCard.tsx | 83 +++ src/components/online/DailyRewardModal.tsx | 113 ++++ .../online/PostMatchRewardsModal.tsx | 165 +++++ src/components/online/RankBadge.tsx | 39 ++ src/components/online/ScreenHeader.tsx | 39 ++ src/components/online/TopBar.tsx | 52 ++ src/components/online/XpBar.tsx | 31 + src/components/screens/AuthScreen.tsx | 215 +++++++ src/components/screens/FriendsScreen.tsx | 126 ++++ src/components/screens/GameScreen.tsx | 67 ++ src/components/screens/LeaderboardScreen.tsx | 55 ++ src/components/screens/MatchmakingScreen.tsx | 122 ++++ src/components/screens/OnlineLobbyScreen.tsx | 89 +++ src/components/screens/ProfileScreen.tsx | 161 +++++ src/components/screens/RoomScreen.tsx | 237 +++++++ src/components/screens/ShopScreen.tsx | 135 ++++ src/lib/cn.ts | 6 + src/lib/game-store.ts | 228 +++++++ src/lib/hokm/ai.ts | 72 +++ src/lib/hokm/deck.ts | 30 + src/lib/hokm/engine.ts | 293 +++++++++ src/lib/hokm/types.ts | 134 ++++ src/lib/i18n.tsx | 435 +++++++++++++ src/lib/online-store.ts | 131 ++++ src/lib/online/gamification.ts | 286 +++++++++ src/lib/online/mock-service.ts | 607 ++++++++++++++++++ src/lib/online/service.ts | 93 +++ src/lib/online/types.ts | 265 ++++++++ src/lib/session-store.ts | 87 +++ src/lib/ui-store.ts | 36 ++ 41 files changed, 5766 insertions(+), 93 deletions(-) create mode 100644 .claude/launch.json create mode 100644 public/icon.svg create mode 100644 public/manifest.webmanifest create mode 100644 scripts/sim.ts create mode 100644 src/components/GameTable.tsx create mode 100644 src/components/HomeScreen.tsx create mode 100644 src/components/PlayingCard.tsx create mode 100644 src/components/online/DailyRewardModal.tsx create mode 100644 src/components/online/PostMatchRewardsModal.tsx create mode 100644 src/components/online/RankBadge.tsx create mode 100644 src/components/online/ScreenHeader.tsx create mode 100644 src/components/online/TopBar.tsx create mode 100644 src/components/online/XpBar.tsx create mode 100644 src/components/screens/AuthScreen.tsx create mode 100644 src/components/screens/FriendsScreen.tsx create mode 100644 src/components/screens/GameScreen.tsx create mode 100644 src/components/screens/LeaderboardScreen.tsx create mode 100644 src/components/screens/MatchmakingScreen.tsx create mode 100644 src/components/screens/OnlineLobbyScreen.tsx create mode 100644 src/components/screens/ProfileScreen.tsx create mode 100644 src/components/screens/RoomScreen.tsx create mode 100644 src/components/screens/ShopScreen.tsx create mode 100644 src/lib/cn.ts create mode 100644 src/lib/game-store.ts create mode 100644 src/lib/hokm/ai.ts create mode 100644 src/lib/hokm/deck.ts create mode 100644 src/lib/hokm/engine.ts create mode 100644 src/lib/hokm/types.ts create mode 100644 src/lib/i18n.tsx create mode 100644 src/lib/online-store.ts create mode 100644 src/lib/online/gamification.ts create mode 100644 src/lib/online/mock-service.ts create mode 100644 src/lib/online/service.ts create mode 100644 src/lib/online/types.ts create mode 100644 src/lib/session-store.ts create mode 100644 src/lib/ui-store.ts 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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
+ <> + {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 ( +
+
{value}
+
{label}
+
+ ); +} 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 }), +}));