diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index acb7054..5650510 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -135,9 +135,18 @@ public sealed class GameManager public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit); public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction); - public void OnConnected(string userId) => RoomOf(userId)?.SetConnected(userId, true); + private int _online; + public int OnlineCount => Volatile.Read(ref _online); + + public void OnConnected(string userId) + { + Interlocked.Increment(ref _online); + RoomOf(userId)?.SetConnected(userId, true); + } + public void OnDisconnected(string userId) { + if (Interlocked.Decrement(ref _online) < 0) Interlocked.Exchange(ref _online, 0); CancelMatchmaking(userId); RoomOf(userId)?.SetConnected(userId, false); } diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index 1f847de..ff2b3af 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -71,6 +71,7 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/", () => Results.Json(new { service = "Hokm SignalR server", status = "ok" })); +app.MapGet("/api/stats/online", (GameManager m) => Results.Json(new { online = m.OnlineCount })); // --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. --- app.MapPost("/api/auth/otp/request", (OtpRequest req) => diff --git a/src/components/HomeScreen.tsx b/src/components/HomeScreen.tsx index df60a2a..dfc5921 100644 --- a/src/components/HomeScreen.tsx +++ b/src/components/HomeScreen.tsx @@ -12,10 +12,12 @@ import { Users, Wifi, } from "lucide-react"; +import { useEffect, useState } from "react"; import { useGameStore } from "@/lib/game-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore, type Screen } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; +import { getService } from "@/lib/online/service"; import { sound } from "@/lib/sound"; import { SUIT_SYMBOL } from "@/lib/hokm/types"; import { TopBar } from "./online/TopBar"; @@ -64,6 +66,7 @@ export function HomeScreen() { {t("app.title")}

{t("app.subtitle")}

+ {/* primary actions */} @@ -191,6 +194,41 @@ function Tile({ ); } +function OnlinePlayers() { + const { t, locale } = useI18n(); + const [count, setCount] = useState(null); + + useEffect(() => { + let alive = true; + const tick = async () => { + try { + const n = await getService().getOnlineCount(); + if (alive) setCount(n); + } catch { + /* ignore */ + } + }; + tick(); + const id = setInterval(tick, 8000); + return () => { + alive = false; + clearInterval(id); + }; + }, []); + + if (count == null) return null; + const n = new Intl.NumberFormat(locale === "fa" ? "fa-IR" : "en-US").format(count); + return ( +
+ + + + + {t("home.onlineCount", { n })} +
+ ); +} + function FloatingSuits() { const suits = Object.values(SUIT_SYMBOL); const items = Array.from({ length: 8 }, (_, i) => ({ diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 17e21c5..8b231e3 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -27,6 +27,7 @@ const fa: Dict = { "home.start": "بزن بریم", "home.howTo": "آموزش بازی", "home.lang": "English", + "home.onlineCount": "{n} نفر آنلاین", "seat.you": "شما", "team.us": "ما", @@ -249,6 +250,7 @@ const en: Dict = { "home.start": "Let's go", "home.howTo": "How to play", "home.lang": "فارسی", + "home.onlineCount": "{n} players online", "seat.you": "You", "team.us": "Us", diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 35dbf49..3aecc4f 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -737,6 +737,14 @@ export class MockOnlineService implements OnlineService { /* --------------------- leaderboard / shop / daily ------------------ */ + private onlineCount = 600 + Math.floor(Math.random() * 900); + async getOnlineCount(): Promise { + // gentle random walk so the badge feels alive + this.onlineCount += Math.round((Math.random() - 0.5) * 40); + this.onlineCount = Math.max(120, Math.min(6000, this.onlineCount)); + return this.onlineCount; + } + async getLeaderboard(): Promise { const p = await this.getProfile(); const others = Array.from({ length: 24 }, () => ({ diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index 3510d33..08e91ed 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -98,6 +98,9 @@ export interface OnlineService { getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null; submitMatchResult(summary: MatchSummary): Promise; + /* ----- stats ----- */ + getOnlineCount(): Promise; + /* ----- leaderboard / shop / daily ----- */ getLeaderboard(): Promise; getShopItems(): Promise; diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index c51b377..7b87f45 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -262,6 +262,19 @@ export class SignalrService implements OnlineService { markRead(id: string) { return this.mock.markRead(id); } onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); } + async getOnlineCount(): Promise { + try { + const res = await fetch(`${SERVER}/api/stats/online`); + if (res.ok) { + const j = (await res.json()) as { online: number }; + return j.online ?? 0; + } + } catch { + /* fall through */ + } + return this.mock.getOnlineCount(); + } + getLeaderboard(): Promise { return this.mock.getLeaderboard(); } getShopItems(): Promise { return this.mock.getShopItems(); } buyItem(id: string) { return this.mock.buyItem(id); }