"use client"; import * as signalR from "@microsoft/signalr"; import { Suit } from "../hokm/types"; import { MockOnlineService } from "./mock-service"; import { CreateRoomOptions, MatchmakingOptions, OnlineService, Unsubscribe, } from "./service"; import { AppNotification, AuthSession, ChatMessage, CoinPack, Conversation, DailyRewardState, ForfeitRequest, Friend, FriendRequest, LeaderboardEntry, PlayerSummary, PublicProfile, ReportReason, MatchSummary, MatchmakingState, RewardResult, Room, RoomInvite, ServerGameState, ShopItem, UserProfile, } from "./types"; const SERVER = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5005"; const LS_SESSION = "hokm.session"; /** Raw private-room shape pushed by the server (kind: empty|invited|bot|human). */ interface ServerRoom { id: string; code: string; hostId: string; status: string; targetScore: number; stake: number; ranked: boolean; seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; avatarImage?: string; level: number } }[]; } const EMPTY_ROOM: Room = { id: "", code: "", hostId: "", status: "open", seats: [], targetScore: 7, stake: 0, ranked: false, }; /** * Talks to the .NET SignalR backend for auth, matchmaking, live game state and * reactions. Everything not yet server-backed (profile, friends, shop, daily, * leaderboard, chat, private rooms) is delegated to the local mock so the app * stays fully functional during the incremental migration. */ export class SignalrService implements OnlineService { readonly live = true; private mock = new MockOnlineService(); private conn: signalR.HubConnection | null = null; private session: AuthSession | null = null; private token: string | null = null; private mmRanked = true; private mmStake = 0; private mmCbs = new Set<(s: MatchmakingState) => void>(); private stateCbs = new Set<(s: ServerGameState) => void>(); // Last server game state, cached so a late subscriber gets the current state. // enterServerMatch subscribes via a React effect that runs AFTER the ordered // "matchFound"→"state" messages have already been dispatched, so without this // the initial broadcast is dropped and the table freezes on the green felt. private lastState: ServerGameState | null = null; private reactionCbs = new Set<(seat: number, reaction: string) => void>(); private notifCbs = new Set<(n: AppNotification) => void>(); private profileCbs = new Set<(p: UserProfile) => void>(); private rewardCbs = new Set<(r: RewardResult) => void>(); private friendCbs = new Set<(f: Friend[]) => void>(); private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>(); private typingCbs = new Set<(fromId: string) => void>(); private forfeitCbs = new Set<(r: ForfeitRequest | null) => void>(); private roomCbs = new Set<(r: Room) => void>(); private roomInviteCbs = new Set<(i: RoomInvite | null) => void>(); private roomWaiters: ((r: Room) => void)[] = []; private lastRoom: Room | null = null; private cachedProfile: UserProfile | null = null; constructor() { if (typeof window !== "undefined") { try { const raw = localStorage.getItem(LS_SESSION); if (raw) { this.session = JSON.parse(raw) as AuthSession; this.token = this.session.token; } } catch { /* ignore */ } } } /* ------------------------------ helpers ---------------------------- */ private async api(path: string, body: unknown): Promise { // Bound the request so a hung/lost response (CDN, network) surfaces an error // instead of freezing the UI (e.g. the OTP "sending…" button forever). const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 20000); let res: Response; try { res = await fetch(`${SERVER}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: ctrl.signal, }); } finally { clearTimeout(timer); } if (!res.ok) throw new Error(await res.text()); return (await res.json()) as T; } private authHeaders(): Record { return this.token ? { Authorization: `Bearer ${this.token}` } : {}; } private async getJson(path: string): Promise { const res = await fetch(`${SERVER}${path}`, { headers: this.authHeaders() }); if (!res.ok) throw new Error(await res.text()); return (await res.json()) as T; } private async send(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${SERVER}${path}`, { method, headers: { "Content-Type": "application/json", ...this.authHeaders() }, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(await res.text()); return (await res.json()) as T; } private async connect(): Promise { if (this.conn || !this.token) return; const conn = new signalR.HubConnectionBuilder() // Force Long-Polling: plain HTTP POST/GET that works through the existing // nginx/CDN (same path REST already uses) without needing WebSocket-upgrade // headers. SignalR holds the poll open, so for a turn-based game it's // effectively real-time. Switch back to default transports once the api // server block proxies WS upgrades. .withUrl(`${SERVER}/hub/game`, { accessTokenFactory: () => this.token ?? "", transport: signalR.HttpTransportType.LongPolling, }) .withAutomaticReconnect() .configureLogging(signalR.LogLevel.Warning) .build(); conn.on("matchmaking", (s: { phase: string; players: number; queuePosition: number | null }) => this.emitMM(s.phase, s.queuePosition ?? undefined)); conn.on("matchFound", () => { this.emitMM("ready"); // Safety net: if the initial state never lands (dropped/raced), ask the // server to resend it so the table can't freeze on the green felt. const before = this.lastState; setTimeout(() => { if (this.lastState === before) this.conn?.invoke("Resync").catch(() => {}); }, 2500); }); conn.on("state", (s: ServerGameState) => { this.lastState = s; this.stateCbs.forEach((cb) => cb(s)); }); conn.on("reaction", (r: { seat: number; reaction: string }) => this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction))); conn.on("typing", (m: { from: string }) => this.typingCbs.forEach((cb) => cb(m.from))); conn.on("room", (r: ServerRoom) => { const room = this.mapRoom(r); this.lastRoom = room; this.roomWaiters.splice(0).forEach((w) => w(room)); this.roomCbs.forEach((cb) => cb(room)); }); conn.on("roomInvite", (i: RoomInvite) => this.roomInviteCbs.forEach((cb) => cb(i))); conn.on("roomInviteCancelled", () => this.roomInviteCbs.forEach((cb) => cb(null))); conn.on("roomClosed", () => { this.lastRoom = null; this.roomInviteCbs.forEach((cb) => cb(null)); }); conn.on("notification", (n: AppNotification) => this.notifCbs.forEach((cb) => cb(n))); conn.on("profile", (p: UserProfile) => { this.cachedProfile = p; this.profileCbs.forEach((cb) => cb(p)); }); conn.on("reward", (r: RewardResult) => this.rewardCbs.forEach((cb) => cb(r))); conn.on("friendRequest", (from: Friend) => { this.notifCbs.forEach((cb) => cb({ id: `n_fr_${from.id}`, kind: "friend_request", titleFa: "درخواست دوستی جدید", titleEn: "New friend request", bodyFa: `${from.displayName} می‌خواهد با شما دوست شود`, bodyEn: `${from.displayName} wants to be your friend`, icon: "👥", ts: Date.now(), read: false, } as AppNotification)); void this.refreshFriends(); }); conn.on("social", () => void this.refreshFriends()); conn.on("chat", (m: { peerId: string }) => void this.emitChat(m.peerId)); conn.on("forfeit", (r: ForfeitRequest | null) => this.forfeitCbs.forEach((cb) => cb(r && r.byName ? r : null))); this.conn = conn; try { await conn.start(); } catch { this.conn = null; // server unreachable; UI degrades gracefully } } private emitMM(phase: string, queuePosition?: number) { const state: MatchmakingState = { phase: phase as MatchmakingState["phase"], players: [], elapsedMs: 0, ranked: this.mmRanked, stake: this.mmStake, queuePosition, }; this.mmCbs.forEach((cb) => cb(state)); } private async setSession( r: { token: string; userId: string; name: string }, method: AuthSession["method"] ): Promise { const session: AuthSession = { userId: r.userId, token: r.token, method, createdAt: Date.now() }; this.session = session; this.token = r.token; if (typeof window !== "undefined") localStorage.setItem(LS_SESSION, JSON.stringify(session)); await this.connect(); return session; } /* ------------------------------- auth ------------------------------ */ getSession() { return this.session; } async restore() { if (this.session && this.token) { void this.connect(); try { return { session: this.session, profile: await this.getProfile() }; } catch { return { session: this.session, profile: await this.mock.getProfile() }; } } return null; } requestOtp(phone: string) { return this.api<{ devCode?: string }>("/api/auth/otp/request", { phone }); } async verifyOtp(phone: string, code: string) { const r = await this.api<{ token: string; userId: string; name: string }>( "/api/auth/otp/verify", { phone, code }); return this.setSession(r, "phone"); } async signInEmail(email: string, password: string) { const r = await this.api<{ token: string; userId: string; name: string }>( "/api/auth/email", { email, password }); return this.setSession(r, "email"); } async signUpEmail(email: string, password: string, displayName: string) { const r = await this.api<{ token: string; userId: string; name: string }>( "/api/auth/email", { email, password, name: displayName }); return this.setSession(r, "email"); } async signInGoogle() { // Server has no Google flow yet — mint a token via the email endpoint. const email = `google_${Math.random().toString(36).slice(2, 8)}@hokm.app`; const r = await this.api<{ token: string; userId: string; name: string }>( "/api/auth/email", { email, password: "google" }); return this.setSession(r, "google"); } async signOut() { this.session = null; this.token = null; this.cachedProfile = null; // drop the signed-in profile so it can't leak post-logout if (typeof window !== "undefined") localStorage.removeItem(LS_SESSION); await this.mock.signOut(); // also clear the guest/fallback profile (hokm.profile) await this.conn?.stop(); this.conn = null; } /* --------------------------- matchmaking --------------------------- */ async startMatchmaking(opts: MatchmakingOptions) { this.mmRanked = opts.ranked; this.mmStake = opts.stake; this.lastState = null; // fresh match — don't replay a prior game's final state await this.connect(); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); this.emitMM("searching"); await this.conn?.invoke("StartMatchmaking", { name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage, }); } async cancelMatchmaking() { await this.conn?.invoke("CancelMatchmaking"); this.emitMM("idle"); } async playNow() { await this.conn?.invoke("PlayNow"); } onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe { this.mmCbs.add(cb); return () => this.mmCbs.delete(cb); } getMatchPlayers() { return null; // server streams identities via the state event } async submitMatchResult(summary: MatchSummary): Promise { // Used for client-run (private/casual) games; server-run ranked rewards // arrive via the "reward" hub event instead. const r = await this.send<{ reward: RewardResult; profile: UserProfile }>( "POST", "/api/match/result", summary); this.cachedProfile = r.profile; this.profileCbs.forEach((cb) => cb(r.profile)); return r.reward; } /* ------------------------------ live game -------------------------- */ onState(cb: (s: ServerGameState) => void): Unsubscribe { this.stateCbs.add(cb); // Replay the current state to this late subscriber on a microtask — after the // caller (enterServerMatch) finishes resetting its store — so a match entered // just after the initial broadcast renders instead of freezing on empty felt. const snapshot = this.lastState; if (snapshot) queueMicrotask(() => { if (this.stateCbs.has(cb)) cb(snapshot); }); return () => this.stateCbs.delete(cb); } playCard(cardId: string) { void this.conn?.invoke("PlayCard", cardId); } chooseTrump(suit: Suit) { void this.conn?.invoke("ChooseTrump", suit); } /* ----------------------------- reactions --------------------------- */ async sendReaction(reaction: string) { await this.conn?.invoke("SendReaction", reaction); } onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe { this.reactionCbs.add(cb); return () => this.reactionCbs.delete(cb); } onProfile(cb: (p: UserProfile) => void): Unsubscribe { this.profileCbs.add(cb); return () => this.profileCbs.delete(cb); } onReward(cb: (r: RewardResult) => void): Unsubscribe { this.rewardCbs.add(cb); return () => this.rewardCbs.delete(cb); } requestForfeit() { void this.conn?.invoke("RequestForfeit"); } confirmForfeit() { void this.conn?.invoke("ConfirmForfeit"); } declineForfeit() { void this.conn?.invoke("DeclineForfeit"); } onForfeit(cb: (r: ForfeitRequest | null) => void): Unsubscribe { this.forfeitCbs.add(cb); return () => this.forfeitCbs.delete(cb); } /* ----- profile / economy → server (authoritative) ----- */ async getProfile() { if (!this.token) return this.mock.getProfile(); // guest / pre-login try { const p = await this.getJson("/api/profile"); this.cachedProfile = p; return p; } catch { return this.mock.getProfile(); // server unreachable → degrade } } async updateProfile(patch: Parameters[0]) { const p = await this.send("PUT", "/api/profile", patch); this.cachedProfile = p; return p; } async upgradePlan() { const p = await this.send("POST", "/api/profile/plan", {}); this.cachedProfile = p; return p; } private async refreshFriends() { try { const f = await this.listFriends(); this.friendCbs.forEach((cb) => cb(f)); } catch { /* ignore */ } } private async emitChat(peerId: string) { try { const m = await this.getMessages(peerId); this.chatCbs.forEach((cb) => cb(peerId, m)); } catch { /* ignore */ } } listFriends() { return this.getJson("/api/friends"); } listRequests() { return this.getJson("/api/friends/requests"); } addFriend(q: string) { return this.send<{ ok: boolean; messageFa: string; messageEn: string }>( "POST", "/api/friends/add", { query: q }); } addFriendById(userId: string) { return this.send<{ ok: boolean; messageFa: string; messageEn: string }>( "POST", "/api/friends/add", { userId }); } getPublicProfile(userId: string): Promise { return this.getJson(`/api/profile/${encodeURIComponent(userId)}/public`); } searchPlayers(query: string): Promise { return this.getJson(`/api/players/search?q=${encodeURIComponent(query)}`); } suggestedPlayers(): Promise { return this.getJson("/api/players/suggested"); } async acceptRequest(id: string) { await this.send("POST", "/api/friends/accept", { id }); } async declineRequest(id: string) { await this.send("POST", "/api/friends/decline", { id }); } async removeFriend(id: string) { await this.send("POST", "/api/friends/remove", { id }); } async reportUser(targetId: string, reason: ReportReason, details?: string) { await this.send("POST", "/api/report", { targetId, reason, details }); return { ok: true }; } onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); } // --- private rooms (server-authoritative, real friend invites) --- private mapRoom(r: ServerRoom): Room { const myId = this.session?.userId; return { id: r.id, code: r.code, hostId: r.hostId, status: "open", targetScore: r.targetScore, stake: r.stake, ranked: r.ranked, seats: r.seats.map((s) => ({ seat: s.seat as 0 | 1 | 2 | 3, kind: s.kind === "empty" ? "empty" : s.kind === "bot" ? "bot" : s.kind === "invited" ? "invited" : s.player?.id === myId ? "you" : "friend", player: s.player, })), }; } private waitRoom(): Promise { return new Promise((resolve) => { this.roomWaiters.push(resolve); setTimeout(() => { const i = this.roomWaiters.indexOf(resolve); if (i >= 0) { this.roomWaiters.splice(i, 1); resolve(this.lastRoom ?? EMPTY_ROOM); } }, 5000); }); } async createRoom(o: CreateRoomOptions) { this.lastState = null; // fresh match — don't replay a prior game's final state await this.connect(); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); await this.conn?.invoke("CreatePrivateRoom", { name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage }, o.stake, o.targetScore); return this.waitRoom(); } async setPartner(_roomId: string, friendId: string | null) { if (friendId) await this.conn?.invoke("InvitePrivate", 2, friendId); else await this.conn?.invoke("ClearPrivateSeat", 2); return this.lastRoom ?? EMPTY_ROOM; } async inviteToSeat(_roomId: string, seat: 1 | 3, friendId: string) { await this.conn?.invoke("InvitePrivate", seat, friendId); return this.lastRoom ?? EMPTY_ROOM; } async addBot(_roomId: string, seat: 1 | 2 | 3) { await this.conn?.invoke("AddPrivateBot", seat); return this.lastRoom ?? EMPTY_ROOM; } async clearSeat(_roomId: string, seat: 1 | 2 | 3) { await this.conn?.invoke("ClearPrivateSeat", seat); return this.lastRoom ?? EMPTY_ROOM; } async startRoom(_roomId: string) { await this.conn?.invoke("StartPrivate"); return this.lastRoom ?? EMPTY_ROOM; } async leaveRoom(_roomId: string) { await this.conn?.invoke("LeavePrivate"); } onRoom(cb: (r: Room) => void) { this.roomCbs.add(cb); return () => this.roomCbs.delete(cb); } async acceptInvite() { this.lastState = null; await this.connect(); await this.conn?.invoke("AcceptPrivate"); } async declineInvite() { await this.conn?.invoke("DeclinePrivate"); } onRoomInvite(cb: (i: RoomInvite | null) => void) { this.roomInviteCbs.add(cb); return () => this.roomInviteCbs.delete(cb); } listConversations(): Promise { return this.getJson("/api/chat"); } getMessages(id: string): Promise { return this.getJson(`/api/chat/messages?peer=${encodeURIComponent(id)}`); } sendMessage(id: string, text: string) { return this.send("POST", "/api/chat/send", { peerId: id, text }); } async markRead() { /* server marks read when messages are fetched */ } onChat(cb: (id: string, m: ChatMessage[]) => void) { this.chatCbs.add(cb); return () => this.chatCbs.delete(cb); } sendTyping(id: string) { this.conn?.invoke("Typing", id).catch(() => {}); } onTyping(cb: (fromId: string) => void) { this.typingCbs.add(cb); return () => this.typingCbs.delete(cb); } onNotification(cb: (n: AppNotification) => void): Unsubscribe { // Real notifications only — server hub "notification" events + app-generated // ones (friend requests, achievements, daily, payment). No fake spam. this.notifCbs.add(cb); return () => this.notifCbs.delete(cb); } async getOnlineCount(): Promise { // Real count from the server (no fabricated floor). try { const res = await fetch(`${SERVER}/api/stats/online`); if (res.ok) { const j = (await res.json()) as { online: number }; return Math.max(0, j.online ?? 0); } } catch { /* server unreachable */ } return 0; } async getLeaderboard(): Promise { // Real, server-ranked leaderboard. return this.getJson("/api/leaderboard"); } // shop catalog stays client-side; the purchase is server-authoritative getShopItems(): Promise { return this.mock.getShopItems(); } async buyItem(id: string) { const item = (await this.mock.getShopItems()).find((i) => i.id === id); if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" }; try { const r = await this.send<{ ok: boolean; profile?: UserProfile }>( "POST", "/api/shop/buy", { kind: item.kind, id, price: item.price }); if (r.profile) this.cachedProfile = r.profile; return { ok: true, profile: r.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" }; } catch { return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; } } getDailyState(): Promise { return this.getJson("/api/daily"); } async claimDaily() { const r = await this.send<{ reward: number; profile: UserProfile; day: number }>( "POST", "/api/daily/claim", {}); this.cachedProfile = r.profile; return r; } getCoinPacks(): Promise { return this.getJson("/api/coins/packs"); } async buyCoins(id: string) { // Real money → start a ZarinPal payment and hand back the redirect URL. const r = await this.send<{ ok: boolean; url?: string }>( "POST", "/api/coins/pay/request", { packId: id }); return { ok: r.ok, coins: 0, redirectUrl: r.url }; } async verifyIab(store: string, productId: string, token: string) { try { const r = await this.send<{ ok: boolean; profile?: UserProfile; coins: number }>( "POST", "/api/coins/iab/verify", { store, productId, token }); if (r.profile) this.cachedProfile = r.profile; return { ok: r.ok, profile: r.profile, coins: r.coins ?? 0 }; } catch { return { ok: false, coins: 0 }; } } }