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); }