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