feat(rooms): real server-side private games with friend invites (no bot swap)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 27s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m16s

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>
This commit is contained in:
soroush.asadi
2026-06-15 15:59:28 +03:30
parent 6530096994
commit a35acea7e4
12 changed files with 528 additions and 10 deletions
+10
View File
@@ -239,6 +239,11 @@ const fa: Dict = {
"room.addBot": "ربات",
"room.empty": "خالی",
"room.waiting": "در انتظار پذیرش…",
"room.waitAccept": "تا وقتی دوستت دعوت را نپذیرد بازی شروع نمی‌شود",
"invite.title": "دعوت به بازی",
"invite.body": "{name} شما را به یک بازی خصوصی دعوت کرد",
"invite.accept": "بپذیر",
"invite.decline": "رد",
"room.cancelInvite": "لغو دعوت",
"room.start": "شروع بازی",
"room.stake": "سکه ورودی",
@@ -620,6 +625,11 @@ const en: Dict = {
"room.addBot": "Bot",
"room.empty": "Empty",
"room.waiting": "Waiting to accept…",
"room.waitAccept": "The game won't start until your friend accepts",
"invite.title": "Game invite",
"invite.body": "{name} invited you to a private game",
"invite.accept": "Accept",
"invite.decline": "Decline",
"room.cancelInvite": "Cancel invite",
"room.start": "Start game",
"room.stake": "Entry coins",
+11
View File
@@ -32,6 +32,8 @@ interface OnlineStore {
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>;
@@ -133,6 +135,15 @@ export const useOnlineStore = create<OnlineStore>((set, get) => ({
}
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();
+7
View File
@@ -868,6 +868,13 @@ export class MockOnlineService implements OnlineService {
return () => this.roomCbs.delete(cb);
}
// Offline mock has no real cross-device invites — these are inert.
async acceptInvite() {}
async declineInvite() {}
onRoomInvite(): Unsubscribe {
return () => {};
}
/* --------------------------- matchmaking --------------------------- */
async startMatchmaking(opts: MatchmakingOptions) {
+6
View File
@@ -17,6 +17,7 @@ import {
PlayerSummary,
PublicProfile,
ReportReason,
RoomInvite,
MatchSummary,
MatchmakingState,
RewardResult,
@@ -122,6 +123,11 @@ export interface OnlineService {
startRoom(roomId: string): Promise<Room>;
leaveRoom(roomId: string): Promise<void>;
onRoom(cb: (room: Room) => void): Unsubscribe;
/** Respond to an incoming room invite (join their room / decline). */
acceptInvite(): Promise<void>;
declineInvite(): Promise<void>;
/** An invite arrived (or null when it was cancelled). */
onRoomInvite(cb: (invite: RoomInvite | null) => void): Unsubscribe;
/* ----- matchmaking ----- */
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
+92 -8
View File
@@ -27,6 +27,7 @@ import {
MatchmakingState,
RewardResult,
Room,
RoomInvite,
ServerGameState,
ShopItem,
UserProfile,
@@ -35,6 +36,22 @@ import {
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; 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,
@@ -61,6 +78,10 @@ export class SignalrService implements OnlineService {
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() {
@@ -133,6 +154,18 @@ export class SignalrService implements OnlineService {
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) =>
@@ -399,14 +432,65 @@ export class SignalrService implements OnlineService {
}
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); }
// --- 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) {
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 }, 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() { 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[]> {
+8
View File
@@ -356,6 +356,14 @@ export interface Room {
ranked: boolean;
}
/** An incoming invitation to someone else's private room. */
export interface RoomInvite {
roomId: string;
code: string;
hostName: string;
stake: number;
}
/* --------------------------- Matchmaking ----------------------------- */
export type MatchmakingPhase =