"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 { AuthSession, ChatMessage, Conversation, DailyRewardState, 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>(); 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 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))); 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)); const profile = await this.mock.getProfile(); if (r.name && profile.displayName === "بازیکن") await this.mock.updateProfile({ displayName: r.name }); await this.connect(); return session; } /* ------------------------------- auth ------------------------------ */ getSession() { return this.session; } async restore() { if (this.session && this.token) { void this.connect(); 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 = await 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 } submitMatchResult(summary: MatchSummary): Promise { return this.mock.submitMatchResult(summary); // local rewards until server-side rewards land } /* ------------------------------ 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); } /* ----- delegated to the mock (not yet on the server) ----- */ getProfile() { return this.mock.getProfile(); } updateProfile(p: Parameters[0]) { return this.mock.updateProfile(p); } upgradePlan() { return this.mock.upgradePlan(); } listFriends() { return this.mock.listFriends(); } listRequests() { return this.mock.listRequests(); } addFriend(q: string) { return this.mock.addFriend(q); } acceptRequest(id: string) { return this.mock.acceptRequest(id); } declineRequest(id: string) { return this.mock.declineRequest(id); } removeFriend(id: string) { return this.mock.removeFriend(id); } onFriends(cb: (f: Friend[]) => void) { return this.mock.onFriends(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.mock.listConversations(); } getMessages(id: string): Promise { return this.mock.getMessages(id); } sendMessage(id: string, text: string) { return this.mock.sendMessage(id, text); } markRead(id: string) { return this.mock.markRead(id); } onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); } getLeaderboard(): Promise { return this.mock.getLeaderboard(); } getShopItems(): Promise { return this.mock.getShopItems(); } buyItem(id: string) { return this.mock.buyItem(id); } getDailyState(): Promise { return this.mock.getDailyState(); } claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }> { return this.mock.claimDaily(); } }