a35acea7e4
Private rooms were 100% client-simulated (the "friend" auto-accepted then bots filled invited seats). Now they're server-authoritative over SignalR: Server (GameManager.PrivateRooms + GameHub): - Room registry with create/invite/accept/decline/addBot/clearSeat/start/leave. - Invite pushes a `roomInvite` to that user (Clients.User); the seat stays "invited" (a pending guest with their real profile, resolved server-side) — it is NEVER replaced by a bot. - StartPrivate refuses while any invite is pending; only EMPTY seats fill with bots. Then it spins up a live GameRoom and matchFound → both devices enter. - Host leave / disconnect closes the room (roomClosed); members free their seat. Client: - signalr-service implements the room methods over the hub (+ room/roomInvite/ roomClosed events, room mapping, onRoomInvite); mock keeps offline no-ops. - online-store accept/declineInvite; RoomScreen blocks "Start" while an invite is pending and auto-enters the live game on matchFound (host + friend). - New global InviteModal (accept/decline) + i18n (invite.*, room.waitAccept). Addresses: (1) no bot replacement, (2) game waits for acceptance, (3) invited friend shown as a pending guest with their name/avatar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
6.2 KiB
TypeScript
212 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import { create } from "zustand";
|
|
import { CreateRoomOptions, MatchmakingOptions, getService } from "./online/service";
|
|
import { pushNotification } from "./notification-store";
|
|
import {
|
|
ChatMessage,
|
|
Friend,
|
|
FriendRequest,
|
|
LeaderboardEntry,
|
|
MatchmakingState,
|
|
Room,
|
|
} from "./online/types";
|
|
|
|
interface OnlineStore {
|
|
friends: Friend[];
|
|
requests: FriendRequest[];
|
|
room: Room | null;
|
|
matchmaking: MatchmakingState;
|
|
leaderboard: LeaderboardEntry[];
|
|
|
|
loadFriends: () => Promise<void>;
|
|
addFriend: (q: string) => Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
|
|
acceptRequest: (id: string) => Promise<void>;
|
|
declineRequest: (id: string) => Promise<void>;
|
|
removeFriend: (id: string) => Promise<void>;
|
|
|
|
createRoom: (opts: CreateRoomOptions) => Promise<void>;
|
|
setPartner: (friendId: string | null) => Promise<void>;
|
|
inviteToSeat: (seat: 1 | 3, friendId: string) => Promise<void>;
|
|
addBot: (seat: 1 | 2 | 3) => Promise<void>;
|
|
clearSeat: (seat: 1 | 2 | 3) => Promise<void>;
|
|
startRoom: () => Promise<void>;
|
|
leaveRoom: () => Promise<void>;
|
|
acceptInvite: () => Promise<void>;
|
|
declineInvite: () => Promise<void>;
|
|
|
|
startMatchmaking: (opts: MatchmakingOptions) => Promise<void>;
|
|
cancelMatchmaking: () => Promise<void>;
|
|
|
|
loadLeaderboard: () => Promise<void>;
|
|
|
|
// chat
|
|
activeChatFriend: Friend | null;
|
|
chatMessages: ChatMessage[];
|
|
unread: number; // total unread messages across conversations (for nav badge)
|
|
openChat: (friend: Friend) => Promise<void>;
|
|
sendChat: (text: string) => Promise<void>;
|
|
closeChat: () => void;
|
|
refreshUnread: () => Promise<void>;
|
|
}
|
|
|
|
let roomUnsub: (() => void) | null = null;
|
|
let mmUnsub: (() => void) | null = null;
|
|
let friendUnsub: (() => void) | null = null;
|
|
let chatUnsub: (() => void) | null = null;
|
|
const seenRequests = new Set<string>();
|
|
|
|
export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
|
friends: [],
|
|
requests: [],
|
|
room: null,
|
|
matchmaking: { phase: "idle", players: [], elapsedMs: 0, ranked: true, stake: 0 },
|
|
leaderboard: [],
|
|
|
|
loadFriends: async () => {
|
|
const svc = getService();
|
|
const [friends, requests] = await Promise.all([svc.listFriends(), svc.listRequests()]);
|
|
set({ friends, requests });
|
|
for (const r of requests) {
|
|
if (seenRequests.has(r.id)) continue;
|
|
seenRequests.add(r.id);
|
|
pushNotification({
|
|
kind: "friend_request",
|
|
titleFa: "درخواست دوستی جدید",
|
|
titleEn: "New friend request",
|
|
bodyFa: r.from.displayName,
|
|
bodyEn: r.from.displayName,
|
|
icon: "👥",
|
|
});
|
|
}
|
|
if (!friendUnsub) friendUnsub = svc.onFriends((f) => set({ friends: f }));
|
|
},
|
|
|
|
addFriend: async (q) => {
|
|
const res = await getService().addFriend(q);
|
|
if (res.ok) await get().loadFriends();
|
|
return res;
|
|
},
|
|
acceptRequest: async (id) => {
|
|
await getService().acceptRequest(id);
|
|
const requests = await getService().listRequests();
|
|
set({ requests });
|
|
},
|
|
declineRequest: async (id) => {
|
|
await getService().declineRequest(id);
|
|
set({ requests: get().requests.filter((r) => r.id !== id) });
|
|
},
|
|
removeFriend: async (id) => {
|
|
await getService().removeFriend(id);
|
|
},
|
|
|
|
createRoom: async (opts) => {
|
|
const svc = getService();
|
|
const room = await svc.createRoom(opts);
|
|
set({ room });
|
|
if (roomUnsub) roomUnsub();
|
|
roomUnsub = svc.onRoom((r) => set({ room: { ...r } }));
|
|
},
|
|
setPartner: async (friendId) => {
|
|
const r = await getService().setPartner(get().room!.id, friendId);
|
|
set({ room: { ...r } });
|
|
},
|
|
inviteToSeat: async (seat, friendId) => {
|
|
const r = await getService().inviteToSeat(get().room!.id, seat, friendId);
|
|
set({ room: { ...r } });
|
|
},
|
|
addBot: async (seat) => {
|
|
const r = await getService().addBot(get().room!.id, seat);
|
|
set({ room: { ...r } });
|
|
},
|
|
clearSeat: async (seat) => {
|
|
const r = await getService().clearSeat(get().room!.id, seat);
|
|
set({ room: { ...r } });
|
|
},
|
|
startRoom: async () => {
|
|
const r = await getService().startRoom(get().room!.id);
|
|
set({ room: { ...r } });
|
|
},
|
|
leaveRoom: async () => {
|
|
if (get().room) await getService().leaveRoom(get().room!.id);
|
|
if (roomUnsub) {
|
|
roomUnsub();
|
|
roomUnsub = null;
|
|
}
|
|
set({ room: null });
|
|
},
|
|
acceptInvite: async () => {
|
|
const svc = getService();
|
|
if (roomUnsub) roomUnsub();
|
|
roomUnsub = svc.onRoom((r) => set({ room: { ...r } })); // subscribe first so we catch the room push
|
|
await svc.acceptInvite();
|
|
},
|
|
declineInvite: async () => {
|
|
await getService().declineInvite();
|
|
},
|
|
|
|
startMatchmaking: async (opts) => {
|
|
const svc = getService();
|
|
if (mmUnsub) mmUnsub();
|
|
mmUnsub = svc.onMatchmaking((s) => set({ matchmaking: s }));
|
|
await svc.startMatchmaking(opts);
|
|
},
|
|
cancelMatchmaking: async () => {
|
|
await getService().cancelMatchmaking();
|
|
if (mmUnsub) {
|
|
mmUnsub();
|
|
mmUnsub = null;
|
|
}
|
|
set({ matchmaking: { phase: "idle", players: [], elapsedMs: 0, ranked: true, stake: 0 } });
|
|
},
|
|
|
|
loadLeaderboard: async () => {
|
|
const leaderboard = await getService().getLeaderboard();
|
|
set({ leaderboard });
|
|
},
|
|
|
|
activeChatFriend: null,
|
|
chatMessages: [],
|
|
unread: 0,
|
|
|
|
refreshUnread: async () => {
|
|
try {
|
|
const convs = await getService().listConversations();
|
|
set({ unread: convs.reduce((n, c) => n + (c.unread ?? 0), 0) });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
},
|
|
|
|
openChat: async (friend) => {
|
|
const svc = getService();
|
|
set({ activeChatFriend: friend, chatMessages: await svc.getMessages(friend.id) });
|
|
await svc.markRead(friend.id);
|
|
get().refreshUnread();
|
|
if (chatUnsub) chatUnsub();
|
|
chatUnsub = svc.onChat((friendId, msgs) => {
|
|
const active = get().activeChatFriend;
|
|
if (active && active.id === friendId) {
|
|
set({ chatMessages: msgs });
|
|
svc.markRead(friendId);
|
|
}
|
|
});
|
|
},
|
|
|
|
sendChat: async (text) => {
|
|
const friend = get().activeChatFriend;
|
|
if (!friend || !text.trim()) return;
|
|
await getService().sendMessage(friend.id, text);
|
|
set({ chatMessages: await getService().getMessages(friend.id) });
|
|
},
|
|
|
|
closeChat: () => {
|
|
if (chatUnsub) {
|
|
chatUnsub();
|
|
chatUnsub = null;
|
|
}
|
|
set({ activeChatFriend: null, chatMessages: [] });
|
|
get().refreshUnread();
|
|
},
|
|
}));
|