ceccf70de7
- @microsoft/signalr client implementing OnlineService: REST auth, hub matchmaking, server-driven game state (onState), play/trump, reactions; delegates not-yet-server-backed features (profile/friends/shop/chat/rooms) to the mock. Selected via NEXT_PUBLIC_USE_SERVER=1 (NEXT_PUBLIC_SERVER_URL) - game-store live mode: enterServerMatch + applyServerState (maps server DTO, hides opponent hands, tally + SFX), inputs route to the hub; no local engine - MatchmakingScreen auto-enters the live match when the server signals ready - Verified end-to-end via scripts/live-test.mjs (auth -> hub -> match -> state) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
271 lines
9.3 KiB
TypeScript
271 lines
9.3 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 {
|
|
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<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 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)));
|
|
|
|
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));
|
|
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<RewardResult> {
|
|
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<OnlineService["updateProfile"]>[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<Conversation[]> { return this.mock.listConversations(); }
|
|
getMessages(id: string): Promise<ChatMessage[]> { 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<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
|
|
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }
|
|
buyItem(id: string) { return this.mock.buyItem(id); }
|
|
getDailyState(): Promise<DailyRewardState> { return this.mock.getDailyState(); }
|
|
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }> { return this.mock.claimDaily(); }
|
|
}
|