Files
HokmPlay/src/lib/online/signalr-service.ts
T
soroush.asadi 1ea3b2b8d2
CI/CD / CI - API (dotnet build + engine sim) (push) Failing after 1m41s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m19s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped
Remove fake/periodic notifications (spam)
The mock emitted random "a friend is online / event is live" notifications on a
35s timer and the live service forwarded them. Dropped both — only real
notifications now fire (friend requests, achievements, daily reward, payment,
match-ended, and server hub events).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:38:08 +03:30

440 lines
16 KiB
TypeScript

"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<T>(path: string, body: unknown): Promise<T> {
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<string, string> {
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
}
private async getJson<T>(path: string): Promise<T> {
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<T>(method: string, path: string, body?: unknown): Promise<T> {
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<void> {
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<AuthSession> {
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<RewardResult> {
// 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<UserProfile>("/api/profile");
this.cachedProfile = p;
return p;
} catch {
return this.mock.getProfile(); // server unreachable → degrade
}
}
async updateProfile(patch: Parameters<OnlineService["updateProfile"]>[0]) {
const p = await this.send<UserProfile>("PUT", "/api/profile", patch);
this.cachedProfile = p;
return p;
}
async upgradePlan() {
const p = await this.send<UserProfile>("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<Friend[]>("/api/friends"); }
listRequests() { return this.getJson<FriendRequest[]>("/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<unknown>("POST", "/api/friends/accept", { id }); }
async declineRequest(id: string) { await this.send<unknown>("POST", "/api/friends/decline", { id }); }
async removeFriend(id: string) { await this.send<unknown>("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<Conversation[]> { return this.getJson<Conversation[]>("/api/chat"); }
getMessages(id: string): Promise<ChatMessage[]> {
return this.getJson<ChatMessage[]>(`/api/chat/messages?peer=${encodeURIComponent(id)}`);
}
sendMessage(id: string, text: string) {
return this.send<ChatMessage>("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<number> {
// 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<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
// shop catalog stays client-side; the purchase is server-authoritative
getShopItems(): Promise<ShopItem[]> { 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<DailyRewardState> { return this.getJson<DailyRewardState>("/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<CoinPack[]> { return this.getJson<CoinPack[]>("/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 };
}
}