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
+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();