diff --git a/server/src/Hokm.Server/Game/GameManager.PrivateRooms.cs b/server/src/Hokm.Server/Game/GameManager.PrivateRooms.cs new file mode 100644 index 0000000..e054ff5 --- /dev/null +++ b/server/src/Hokm.Server/Game/GameManager.PrivateRooms.cs @@ -0,0 +1,241 @@ +using System.Collections.Concurrent; +using Hokm.Server.Profiles; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; + +namespace Hokm.Server.Game; + +// Wire DTOs for private rooms (camelCase JSON → TS client types). +public record RoomPlayerDto(string Id, string DisplayName, string Avatar, int Level); +public record RoomSeatDto(int Seat, string Kind, RoomPlayerDto? Player); // kind: empty|invited|bot|human +public record RoomDto(string Id, string Code, string HostId, string Status, List Seats, int TargetScore, int Stake, bool Ranked); +public record RoomInviteDto(string RoomId, string Code, string HostName, int Stake); + +/// +/// Server-authoritative private rooms with REAL friend invites. A seat stays +/// "invited" (a pending guest, NOT a bot) until that user accepts; the host can +/// only start once no invite is pending. On start the room becomes a live +/// GameRoom (empty seats — never pending ones — fill with bots). +/// +public sealed partial class GameManager +{ + private sealed class PSeat + { + public int Seat; + public string Kind = "empty"; // empty | invited | bot | human + public string? UserId; + public string Name = ""; + public string Avatar = "a-fox"; + public int Level; + } + + private sealed class PRoom + { + public string Id = Guid.NewGuid().ToString("N")[..8]; + public string Code = Guid.NewGuid().ToString("N")[..5].ToUpperInvariant(); + public string HostId = ""; + public int Stake; + public int TargetScore = 7; + public PSeat[] Seats = new PSeat[4]; + } + + private readonly ConcurrentDictionary _privateRooms = new(); + private readonly ConcurrentDictionary _userPrivate = new(); // host + accepted → roomId + private readonly ConcurrentDictionary _pendingInvite = new(); // invited userId → roomId + private readonly object _proomLock = new(); + + public void CreatePrivateRoom(Player host, int stake, int target) + { + LeavePrivate(host.UserId); // one room per host + lock (_proomLock) + { + var room = new PRoom { HostId = host.UserId, Stake = stake, TargetScore = target <= 0 ? 7 : target }; + for (int i = 0; i < 4; i++) room.Seats[i] = new PSeat { Seat = i }; + room.Seats[0] = new PSeat { Seat = 0, Kind = "human", UserId = host.UserId, Name = host.Name, Avatar = host.Avatar, Level = host.Level }; + _privateRooms[room.Id] = room; + _userPrivate[host.UserId] = room.Id; + PushRoom(room); + } + } + + public void InvitePrivate(string hostId, int seat, string friendId) + { + if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return; + // Authoritative friend identity (so the pending seat shows their real name/avatar). + var (name, avatar, level) = ResolveProfile(friendId, "", "a-fox", 1); + lock (_proomLock) + { + if (!HostRoom(hostId, out var room)) return; + if (room!.Seats[seat].Kind is not ("empty" or "invited")) return; + if (room.Seats.Any(s => s.Seat != seat && s.UserId == friendId)) return; // already in this room + FreeSeatInvite(room.Seats[seat]); // if re-inviting over a prior invite + room.Seats[seat] = new PSeat { Seat = seat, Kind = "invited", UserId = friendId, Name = name, Avatar = avatar, Level = level }; + _pendingInvite[friendId] = room.Id; + _ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake)); + PushRoom(room); + } + } + + public void AcceptPrivate(string userId) + { + lock (_proomLock) + { + if (!_pendingInvite.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return; + var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId); + if (seat == null) return; + var (name, avatar, level) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.Level); + room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat, Kind = "human", UserId = userId, Name = name, Avatar = avatar, Level = level }; + _userPrivate[userId] = room.Id; + PushRoom(room); + } + } + + public void DeclinePrivate(string userId) + { + lock (_proomLock) + { + if (!_pendingInvite.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return; + var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId); + if (seat != null) room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat }; + PushRoom(room); + } + } + + public void AddPrivateBot(string hostId, int seat) + { + if (seat is < 1 or > 3) return; + lock (_proomLock) + { + if (!HostRoom(hostId, out var room)) return; + FreeSeatInvite(room!.Seats[seat]); + room.Seats[seat] = new PSeat { Seat = seat, Kind = "bot", Name = BotNames[_rng.Next(BotNames.Length)], Avatar = Avatars[_rng.Next(Avatars.Length)], Level = _rng.Next(1, 50) }; + PushRoom(room); + } + } + + public void ClearPrivateSeat(string hostId, int seat) + { + if (seat is < 1 or > 3) return; + lock (_proomLock) + { + if (!HostRoom(hostId, out var room)) return; + FreeSeatInvite(room!.Seats[seat]); + room.Seats[seat] = new PSeat { Seat = seat }; + PushRoom(room); + } + } + + public void StartPrivate(string hostId) + { + SeatSlot[]? slots = null; + int stake = 0, target = 7; + lock (_proomLock) + { + if (!HostRoom(hostId, out var room)) return; + if (room!.Seats.Any(s => s.Kind == "invited")) return; // never start with a pending invite + stake = room.Stake; target = room.TargetScore; + slots = new SeatSlot[4]; + for (int i = 0; i < 4; i++) + { + var s = room.Seats[i]; + slots[i] = s.Kind == "human" + ? new SeatSlot { Seat = i, UserId = s.UserId, Name = s.Name, Avatar = s.Avatar, Level = s.Level } + : new SeatSlot { Seat = i, IsBot = true, + Name = s.Kind == "bot" && s.Name.Length > 0 ? s.Name : BotNames[_rng.Next(BotNames.Length)], + Avatar = s.Kind == "bot" ? s.Avatar : Avatars[_rng.Next(Avatars.Length)], + Level = s.Level > 0 ? s.Level : _rng.Next(1, 50) }; + } + foreach (var s in room.Seats.Where(s => s.UserId != null)) _userPrivate.TryRemove(s.UserId!, out _); + _privateRooms.TryRemove(room.Id, out _); + } + if (slots != null) StartMatchSeats(slots, stake, target); + } + + public void LeavePrivate(string userId) + { + lock (_proomLock) + { + _pendingInvite.TryRemove(userId, out _); + if (!_userPrivate.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return; + if (room.HostId == userId) + { + _privateRooms.TryRemove(room.Id, out _); + foreach (var s in room.Seats.Where(s => s.UserId != null)) + { + _userPrivate.TryRemove(s.UserId!, out _); + _pendingInvite.TryRemove(s.UserId!, out _); + if (s.UserId != userId) _ = _hub.Clients.User(s.UserId!).SendAsync("roomClosed"); + } + } + else + { + var seat = room.Seats.FirstOrDefault(s => s.UserId == userId); + if (seat != null) room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat }; + PushRoom(room); + } + } + } + + // ----------------------------- helpers ----------------------------- + + private bool HostRoom(string hostId, out PRoom? room) + { + room = null; + if (_userPrivate.TryGetValue(hostId, out var id) && _privateRooms.TryGetValue(id, out var r) && r.HostId == hostId) + { + room = r; + return true; + } + return false; + } + + private void FreeSeatInvite(PSeat s) + { + if (s.Kind == "invited" && s.UserId != null) + { + _pendingInvite.TryRemove(s.UserId, out _); + _ = _hub.Clients.User(s.UserId).SendAsync("roomInviteCancelled"); + } + } + + private (string name, string avatar, int level) ResolveProfile(string userId, string fbName, string fbAvatar, int fbLevel) + { + try + { + using var scope = _scopes.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + var p = svc.GetOrCreate(userId, null).GetAwaiter().GetResult(); + return (p.DisplayName, p.Avatar, p.Level); + } + catch { return (fbName, fbAvatar, fbLevel); } + } + + private void PushRoom(PRoom room) + { + var dto = ToDto(room); + // Only accepted members (host + humans) get room state; invited users get the invite event. + foreach (var s in room.Seats.Where(s => s.Kind == "human" && s.UserId != null)) + _ = _hub.Clients.User(s.UserId!).SendAsync("room", dto); + } + + private static RoomDto ToDto(PRoom room) => new( + room.Id, room.Code, room.HostId, "lobby", + room.Seats.Select(SeatDto).ToList(), room.TargetScore, room.Stake, false); + + private static RoomSeatDto SeatDto(PSeat s) => + s.Kind == "empty" + ? new RoomSeatDto(s.Seat, "empty", null) + : new RoomSeatDto(s.Seat, s.Kind, new RoomPlayerDto(s.UserId ?? $"bot-{s.Seat}", s.Name, s.Avatar, s.Level)); + + /// Turn a fixed seat arrangement into a live match (used by private-room start). + private void StartMatchSeats(SeatSlot[] seats, int stake, int targetScore) + { + var room = new GameRoom(_hub, _scopes, seats, ranked: false, stake: stake, targetScore: targetScore); + room.OnFinished = FinishRoom; + _rooms[room.Id] = room; + foreach (var s in seats.Where(s => !s.IsBot && s.UserId != null)) _userRoom[s.UserId!] = room.Id; + foreach (var s in seats.Where(s => !s.IsBot && s.UserId != null)) + _ = _hub.Clients.User(s.UserId!).SendAsync("matchFound", new { roomId = room.Id, seat = room.SeatOf(s.UserId!) }); + room.Start(); + } +} diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index cfa3f58..91f08ae 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -15,7 +15,7 @@ public sealed class Player } /// In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.) -public sealed class GameManager +public sealed partial class GameManager { // Real players get priority: wait exactly 15s for humans to join; whoever // hasn't joined the table by then is replaced with a bot when the match forms. @@ -171,6 +171,7 @@ public sealed class GameManager if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0) _onlineUsers.TryRemove(userId, out _); CancelMatchmaking(userId); + LeavePrivate(userId); // free their private-room seat / close their room RoomOf(userId)?.SetConnected(userId, false); } } diff --git a/server/src/Hokm.Server/Hubs/GameHub.cs b/server/src/Hokm.Server/Hubs/GameHub.cs index c84f9b5..8543418 100644 --- a/server/src/Hokm.Server/Hubs/GameHub.cs +++ b/server/src/Hokm.Server/Hubs/GameHub.cs @@ -44,6 +44,29 @@ public sealed class GameHub : Hub public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid); public void DeclineForfeit() => _manager.DeclineForfeit(Uid); + /* ----------------------- private rooms (friend invites) ----------------------- */ + + public void CreatePrivateRoom(MatchmakeRequest req, int stake, int target) => + _manager.CreatePrivateRoom(PlayerFrom(req), stake, target); + + public void InvitePrivate(int seat, string friendId) => _manager.InvitePrivate(Uid, seat, friendId); + + public void AcceptPrivate() => _manager.AcceptPrivate(Uid); + public void DeclinePrivate() => _manager.DeclinePrivate(Uid); + public void AddPrivateBot(int seat) => _manager.AddPrivateBot(Uid, seat); + public void ClearPrivateSeat(int seat) => _manager.ClearPrivateSeat(Uid, seat); + public void StartPrivate() => _manager.StartPrivate(Uid); + public void LeavePrivate() => _manager.LeavePrivate(Uid); + + private Player PlayerFrom(MatchmakeRequest req) => new() + { + UserId = Uid, + Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name, + Avatar = req.Avatar, + Level = req.Level, + Plan = req.Plan, + }; + /// Notify a chat peer that this user is typing (ephemeral, not stored). public Task Typing(string peerId) => string.IsNullOrWhiteSpace(peerId) diff --git a/src/app/page.tsx b/src/app/page.tsx index 1b8ee24..4c42985 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,6 +22,7 @@ import { ResumeGameBar } from "@/components/online/ResumeGameBar"; import { CelebrationOverlay } from "@/components/online/CelebrationOverlay"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { PublicProfileModal } from "@/components/online/PublicProfileModal"; +import { InviteModal } from "@/components/online/InviteModal"; import { CapacitorBack } from "@/components/CapacitorBack"; import { useSessionStore } from "@/lib/session-store"; import { useGameStore } from "@/lib/game-store"; @@ -209,6 +210,7 @@ export default function Page() { + {loading && null} diff --git a/src/components/online/InviteModal.tsx b/src/components/online/InviteModal.tsx new file mode 100644 index 0000000..c7ac646 --- /dev/null +++ b/src/components/online/InviteModal.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Coins, Users } from "lucide-react"; +import { useEffect, useState } from "react"; +import { getService } from "@/lib/online/service"; +import { useOnlineStore } from "@/lib/online-store"; +import { useUIStore } from "@/lib/ui-store"; +import { useI18n } from "@/lib/i18n"; +import { sound } from "@/lib/sound"; +import type { RoomInvite } from "@/lib/online/types"; + +/** + * Global incoming private-room invite. When a friend invites you, this pops up + * with accept/decline. Accepting joins their room (you appear as a real guest, + * never a bot) and opens the room screen. + */ +export function InviteModal() { + const { t } = useI18n(); + const acceptInvite = useOnlineStore((s) => s.acceptInvite); + const declineInvite = useOnlineStore((s) => s.declineInvite); + const go = useUIStore((s) => s.go); + const [invite, setInvite] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const unsub = getService().onRoomInvite((i) => { + setInvite(i); + if (i) sound.play("notify"); + }); + return unsub; + }, []); + + if (!invite) return null; + + const accept = async () => { + if (busy) return; + setBusy(true); + try { + await acceptInvite(); + go("room"); + } finally { + setBusy(false); + setInvite(null); + } + }; + const decline = async () => { + if (busy) return; + setBusy(true); + try { + await declineInvite(); + } finally { + setBusy(false); + setInvite(null); + } + }; + + return ( + + + +
+ +
+

{t("invite.title")}

+

{t("invite.body").replace("{name}", invite.hostName)}

+ {invite.stake > 0 && ( +

+ {invite.stake.toLocaleString()} +

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/screens/RoomScreen.tsx b/src/components/screens/RoomScreen.tsx index acc87ae..198ec23 100644 --- a/src/components/screens/RoomScreen.tsx +++ b/src/components/screens/RoomScreen.tsx @@ -8,6 +8,7 @@ import { useGameStore } from "@/lib/game-store"; import { useOnlineStore } from "@/lib/online-store"; import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; +import { getService } from "@/lib/online/service"; import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types"; import { cn } from "@/lib/cn"; @@ -31,6 +32,7 @@ export function RoomScreen() { const startRoom = useOnlineStore((s) => s.startRoom); const leaveRoom = useOnlineStore((s) => s.leaveRoom); const newOnlineMatch = useGameStore((s) => s.newOnlineMatch); + const enterServerMatch = useGameStore((s) => s.enterServerMatch); const goGame = useUIStore((s) => s.goGame); const go = useUIStore((s) => s.go); @@ -41,7 +43,22 @@ export function RoomScreen() { loadFriends(); }, [loadFriends]); + // Live: when the host starts, the server sends matchFound to every human seat + // (host + accepted friends) → each device auto-enters the server-run game. + useEffect(() => { + const svc = getService(); + if (!svc.live) return; + const unsub = svc.onMatchmaking((s) => { + if (s.phase === "ready") { + enterServerMatch(svc); + goGame("home"); + } + }); + return unsub; + }, [enterServerMatch, goGame]); + if (!room) return null; + const hasPending = room.seats.some((s) => s.kind === "invited"); const seat = (n: number) => room.seats.find((s) => s.seat === n)!; const statusLabel = (s: PresenceStatus) => s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline"); @@ -64,6 +81,13 @@ export function RoomScreen() { }; const start = async () => { + if (hasPending) return; // never start while a friend's invite is still pending + if (getService().live) { + // Server runs the match; it pushes matchFound → the effect above enters it. + await startRoom(); + return; + } + // Offline mock: build a client-run match from the (bot-filled) seats. await startRoom(); const r = useOnlineStore.getState().room!; const players = r.seats @@ -136,11 +160,18 @@ export function RoomScreen() { + {hasPending && ( +

{t("room.waitAccept")}

+ )}
-
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 2366756..ab00b61 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -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", diff --git a/src/lib/online-store.ts b/src/lib/online-store.ts index 1107781..872029d 100644 --- a/src/lib/online-store.ts +++ b/src/lib/online-store.ts @@ -32,6 +32,8 @@ interface OnlineStore { clearSeat: (seat: 1 | 2 | 3) => Promise; startRoom: () => Promise; leaveRoom: () => Promise; + acceptInvite: () => Promise; + declineInvite: () => Promise; startMatchmaking: (opts: MatchmakingOptions) => Promise; cancelMatchmaking: () => Promise; @@ -133,6 +135,15 @@ export const useOnlineStore = create((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(); diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 1914bc1..341bcbb 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -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) { diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index ef87e6f..cdc87be 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -17,6 +17,7 @@ import { PlayerSummary, PublicProfile, ReportReason, + RoomInvite, MatchSummary, MatchmakingState, RewardResult, @@ -122,6 +123,11 @@ export interface OnlineService { startRoom(roomId: string): Promise; leaveRoom(roomId: string): Promise; onRoom(cb: (room: Room) => void): Unsubscribe; + /** Respond to an incoming room invite (join their room / decline). */ + acceptInvite(): Promise; + declineInvite(): Promise; + /** An invite arrived (or null when it was cancelled). */ + onRoomInvite(cb: (invite: RoomInvite | null) => void): Unsubscribe; /* ----- matchmaking ----- */ startMatchmaking(opts: MatchmakingOptions): Promise; diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index c75f24a..ddc738b 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -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 { + 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 { return this.getJson("/api/chat"); } getMessages(id: string): Promise { diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index d899f9c..84c9084 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -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 =