"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, MatchSummary, MatchmakingState, RewardResult, Room, ServerGameState, ShopItem, UserProfile, } from "./types"; const SERVER = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5005"; const LS_SESSION = "hokm.session"; /** * 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>(); 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 forfeitCbs = new Set<(r: ForfeitRequest | null) => void>(); 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 { const res = await fetch(`${SERVER}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); 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() .withUrl(`${SERVER}/hub/game`, { accessTokenFactory: () => this.token ?? "" }) .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")); conn.on("state", (s: ServerGameState) => 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("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; if (typeof window !== "undefined") localStorage.removeItem(LS_SESSION); await this.conn?.stop(); this.conn = null; } /* --------------------------- matchmaking --------------------------- */ async startMatchmaking(opts: MatchmakingOptions) { this.mmRanked = opts.ranked; this.mmStake = opts.stake; 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, }); } async cancelMatchmaking() { await this.conn?.invoke("CancelMatchmaking"); this.emitMM("idle"); } 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); 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 }); } 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 }); } onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); } createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); } setPartner(roomId: string, friendId: string | null) { return this.mock.setPartner(roomId, friendId); } inviteToSeat(roomId: string, seat: 1 | 3, friendId: string) { return this.mock.inviteToSeat(roomId, seat, friendId); } addBot(roomId: string, seat: 1 | 2 | 3) { return this.mock.addBot(roomId, seat); } clearSeat(roomId: string, seat: 1 | 2 | 3) { return this.mock.clearSeat(roomId, seat); } startRoom(roomId: string) { return this.mock.startRoom(roomId); } leaveRoom(roomId: string) { return this.mock.leaveRoom(roomId); } onRoom(cb: (r: Room) => void) { return this.mock.onRoom(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); } 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 { // Always show a believable floor (≥50) — never the raw small/zero real count. const floor = await this.mock.getOnlineCount(); // drifts, min 50 try { const res = await fetch(`${SERVER}/api/stats/online`); if (res.ok) { const j = (await res.json()) as { online: number }; return Math.max(j.online ?? 0, floor); } } catch { /* fall through */ } return floor; } getLeaderboard(): Promise { return this.mock.getLeaderboard(); } // 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 }; } }