3875141f46
Three changes: 1. GameTable shows a spinner instead of an empty table when mode=online and seatPlayers is empty (waiting for first state broadcast). 2. enterServerMatch schedules a 3s interval that calls service.resync() until seatPlayers is populated, guaranteeing the state always lands. 3. resync() added to OnlineService interface + both implementations so the game store can call it without casting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
619 lines
23 KiB
TypeScript
619 lines
23 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,
|
|
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<T>(path: string, body: unknown): Promise<T> {
|
|
// 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<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()
|
|
// 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; queue?: Array<{ id: string; name: string; avatar: string; avatarImage?: string; level: number }> }) =>
|
|
this.emitMM(s.phase, s.queuePosition ?? undefined, s.players,
|
|
s.queue?.map(p => ({ id: p.id, displayName: p.name, avatar: p.avatar, avatarImage: p.avatarImage, level: p.level, rating: 0 }))));
|
|
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,
|
|
waiting?: number,
|
|
queuePlayers?: Array<{ id: string; displayName: string; avatar: string; avatarImage?: string; level: number; rating: number }>
|
|
) {
|
|
const state: MatchmakingState = {
|
|
phase: phase as MatchmakingState["phase"],
|
|
players: queuePlayers ?? [],
|
|
elapsedMs: 0,
|
|
ranked: this.mmRanked,
|
|
stake: this.mmStake,
|
|
queuePosition,
|
|
waiting,
|
|
};
|
|
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;
|
|
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");
|
|
}
|
|
|
|
resync(): void {
|
|
void this.conn?.invoke("Resync").catch(() => {});
|
|
}
|
|
|
|
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);
|
|
// 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<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 });
|
|
}
|
|
addFriendById(userId: string) {
|
|
return this.send<{ ok: boolean; messageFa: string; messageEn: string }>(
|
|
"POST", "/api/friends/add", { userId });
|
|
}
|
|
getPublicProfile(userId: string): Promise<PublicProfile> {
|
|
return this.getJson<PublicProfile>(`/api/profile/${encodeURIComponent(userId)}/public`);
|
|
}
|
|
searchPlayers(query: string): Promise<PlayerSummary[]> {
|
|
return this.getJson<PlayerSummary[]>(`/api/players/search?q=${encodeURIComponent(query)}`);
|
|
}
|
|
suggestedPlayers(): Promise<PlayerSummary[]> {
|
|
return this.getJson<PlayerSummary[]>("/api/players/suggested");
|
|
}
|
|
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 }); }
|
|
async reportUser(targetId: string, reason: ReportReason, details?: string) {
|
|
await this.send<unknown>("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<Room> {
|
|
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<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); }
|
|
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<number> {
|
|
// 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<LeaderboardEntry[]> {
|
|
// Real, server-ranked leaderboard.
|
|
return this.getJson<LeaderboardEntry[]>("/api/leaderboard");
|
|
}
|
|
|
|
// 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 };
|
|
}
|
|
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 };
|
|
}
|
|
}
|
|
}
|