feat(rooms): real server-side private games with friend invites (no bot swap)
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:
@@ -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<RoomSeatDto> Seats, int TargetScore, int Stake, bool Ranked);
|
||||||
|
public record RoomInviteDto(string RoomId, string Code, string HostName, int Stake);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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<string, PRoom> _privateRooms = new();
|
||||||
|
private readonly ConcurrentDictionary<string, string> _userPrivate = new(); // host + accepted → roomId
|
||||||
|
private readonly ConcurrentDictionary<string, string> _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<ProfileService>();
|
||||||
|
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));
|
||||||
|
|
||||||
|
/// <summary>Turn a fixed seat arrangement into a live match (used by private-room start).</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ public sealed class Player
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
||||||
public sealed class GameManager
|
public sealed partial class GameManager
|
||||||
{
|
{
|
||||||
// Real players get priority: wait exactly 15s for humans to join; whoever
|
// 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.
|
// 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)
|
if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0)
|
||||||
_onlineUsers.TryRemove(userId, out _);
|
_onlineUsers.TryRemove(userId, out _);
|
||||||
CancelMatchmaking(userId);
|
CancelMatchmaking(userId);
|
||||||
|
LeavePrivate(userId); // free their private-room seat / close their room
|
||||||
RoomOf(userId)?.SetConnected(userId, false);
|
RoomOf(userId)?.SetConnected(userId, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,29 @@ public sealed class GameHub : Hub
|
|||||||
public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid);
|
public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid);
|
||||||
public void DeclineForfeit() => _manager.DeclineForfeit(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,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Notify a chat peer that this user is typing (ephemeral, not stored).</summary>
|
/// <summary>Notify a chat peer that this user is typing (ephemeral, not stored).</summary>
|
||||||
public Task Typing(string peerId) =>
|
public Task Typing(string peerId) =>
|
||||||
string.IsNullOrWhiteSpace(peerId)
|
string.IsNullOrWhiteSpace(peerId)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ResumeGameBar } from "@/components/online/ResumeGameBar";
|
|||||||
import { CelebrationOverlay } from "@/components/online/CelebrationOverlay";
|
import { CelebrationOverlay } from "@/components/online/CelebrationOverlay";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
import { PublicProfileModal } from "@/components/online/PublicProfileModal";
|
import { PublicProfileModal } from "@/components/online/PublicProfileModal";
|
||||||
|
import { InviteModal } from "@/components/online/InviteModal";
|
||||||
import { CapacitorBack } from "@/components/CapacitorBack";
|
import { CapacitorBack } from "@/components/CapacitorBack";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
@@ -209,6 +210,7 @@ export default function Page() {
|
|||||||
<ResumeGameBar />
|
<ResumeGameBar />
|
||||||
<CelebrationOverlay />
|
<CelebrationOverlay />
|
||||||
<PublicProfileModal />
|
<PublicProfileModal />
|
||||||
|
<InviteModal />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<CapacitorBack />
|
<CapacitorBack />
|
||||||
{loading && null}
|
{loading && null}
|
||||||
|
|||||||
@@ -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<RoomInvite | null>(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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-navy-950/85 backdrop-blur-sm p-5"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 20 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 240, damping: 22 }}
|
||||||
|
className="glass rounded-3xl p-6 w-full max-w-xs text-center"
|
||||||
|
>
|
||||||
|
<div className="mx-auto grid size-14 place-items-center rounded-2xl btn-gold mb-3">
|
||||||
|
<Users className="size-6" />
|
||||||
|
</div>
|
||||||
|
<h2 className="gold-text text-lg font-black">{t("invite.title")}</h2>
|
||||||
|
<p className="text-cream/75 text-sm mt-2">{t("invite.body").replace("{name}", invite.hostName)}</p>
|
||||||
|
{invite.stake > 0 && (
|
||||||
|
<p className="mt-2 inline-flex items-center gap-1 text-gold-300 text-xs font-bold">
|
||||||
|
{invite.stake.toLocaleString()} <Coins className="size-3.5" />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-5">
|
||||||
|
<button onClick={decline} disabled={busy} className="flex-1 glass rounded-xl py-3 text-cream/70 disabled:opacity-60">
|
||||||
|
{t("invite.decline")}
|
||||||
|
</button>
|
||||||
|
<button onClick={accept} disabled={busy} className="flex-1 btn-gold rounded-xl py-3 font-bold disabled:opacity-60">
|
||||||
|
{t("invite.accept")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { useGameStore } from "@/lib/game-store";
|
|||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { getService } from "@/lib/online/service";
|
||||||
import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types";
|
import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export function RoomScreen() {
|
|||||||
const startRoom = useOnlineStore((s) => s.startRoom);
|
const startRoom = useOnlineStore((s) => s.startRoom);
|
||||||
const leaveRoom = useOnlineStore((s) => s.leaveRoom);
|
const leaveRoom = useOnlineStore((s) => s.leaveRoom);
|
||||||
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
|
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
|
||||||
|
const enterServerMatch = useGameStore((s) => s.enterServerMatch);
|
||||||
const goGame = useUIStore((s) => s.goGame);
|
const goGame = useUIStore((s) => s.goGame);
|
||||||
const go = useUIStore((s) => s.go);
|
const go = useUIStore((s) => s.go);
|
||||||
|
|
||||||
@@ -41,7 +43,22 @@ export function RoomScreen() {
|
|||||||
loadFriends();
|
loadFriends();
|
||||||
}, [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;
|
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 seat = (n: number) => room.seats.find((s) => s.seat === n)!;
|
||||||
const statusLabel = (s: PresenceStatus) =>
|
const statusLabel = (s: PresenceStatus) =>
|
||||||
s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline");
|
s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline");
|
||||||
@@ -64,6 +81,13 @@ export function RoomScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const start = async () => {
|
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();
|
await startRoom();
|
||||||
const r = useOnlineStore.getState().room!;
|
const r = useOnlineStore.getState().room!;
|
||||||
const players = r.seats
|
const players = r.seats
|
||||||
@@ -136,11 +160,18 @@ export function RoomScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasPending && (
|
||||||
|
<p className="text-center text-[11px] text-gold-300/80 mt-5 -mb-2">{t("room.waitAccept")}</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-3 mt-7">
|
<div className="flex gap-3 mt-7">
|
||||||
<button onClick={leave} className="glass rounded-xl px-5 py-3 text-cream/70 hover:text-cream">
|
<button onClick={leave} className="glass rounded-xl px-5 py-3 text-cream/70 hover:text-cream">
|
||||||
{t("room.leave")}
|
{t("room.leave")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={start} className="btn-gold flex-1 rounded-xl py-3 text-lg">
|
<button
|
||||||
|
onClick={start}
|
||||||
|
disabled={hasPending}
|
||||||
|
className="btn-gold flex-1 rounded-xl py-3 text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
{t("room.start")}
|
{t("room.start")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -239,6 +239,11 @@ const fa: Dict = {
|
|||||||
"room.addBot": "ربات",
|
"room.addBot": "ربات",
|
||||||
"room.empty": "خالی",
|
"room.empty": "خالی",
|
||||||
"room.waiting": "در انتظار پذیرش…",
|
"room.waiting": "در انتظار پذیرش…",
|
||||||
|
"room.waitAccept": "تا وقتی دوستت دعوت را نپذیرد بازی شروع نمیشود",
|
||||||
|
"invite.title": "دعوت به بازی",
|
||||||
|
"invite.body": "{name} شما را به یک بازی خصوصی دعوت کرد",
|
||||||
|
"invite.accept": "بپذیر",
|
||||||
|
"invite.decline": "رد",
|
||||||
"room.cancelInvite": "لغو دعوت",
|
"room.cancelInvite": "لغو دعوت",
|
||||||
"room.start": "شروع بازی",
|
"room.start": "شروع بازی",
|
||||||
"room.stake": "سکه ورودی",
|
"room.stake": "سکه ورودی",
|
||||||
@@ -620,6 +625,11 @@ const en: Dict = {
|
|||||||
"room.addBot": "Bot",
|
"room.addBot": "Bot",
|
||||||
"room.empty": "Empty",
|
"room.empty": "Empty",
|
||||||
"room.waiting": "Waiting to accept…",
|
"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.cancelInvite": "Cancel invite",
|
||||||
"room.start": "Start game",
|
"room.start": "Start game",
|
||||||
"room.stake": "Entry coins",
|
"room.stake": "Entry coins",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface OnlineStore {
|
|||||||
clearSeat: (seat: 1 | 2 | 3) => Promise<void>;
|
clearSeat: (seat: 1 | 2 | 3) => Promise<void>;
|
||||||
startRoom: () => Promise<void>;
|
startRoom: () => Promise<void>;
|
||||||
leaveRoom: () => Promise<void>;
|
leaveRoom: () => Promise<void>;
|
||||||
|
acceptInvite: () => Promise<void>;
|
||||||
|
declineInvite: () => Promise<void>;
|
||||||
|
|
||||||
startMatchmaking: (opts: MatchmakingOptions) => Promise<void>;
|
startMatchmaking: (opts: MatchmakingOptions) => Promise<void>;
|
||||||
cancelMatchmaking: () => Promise<void>;
|
cancelMatchmaking: () => Promise<void>;
|
||||||
@@ -133,6 +135,15 @@ export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
set({ room: 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) => {
|
startMatchmaking: async (opts) => {
|
||||||
const svc = getService();
|
const svc = getService();
|
||||||
|
|||||||
@@ -868,6 +868,13 @@ export class MockOnlineService implements OnlineService {
|
|||||||
return () => this.roomCbs.delete(cb);
|
return () => this.roomCbs.delete(cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offline mock has no real cross-device invites — these are inert.
|
||||||
|
async acceptInvite() {}
|
||||||
|
async declineInvite() {}
|
||||||
|
onRoomInvite(): Unsubscribe {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------- matchmaking --------------------------- */
|
/* --------------------------- matchmaking --------------------------- */
|
||||||
|
|
||||||
async startMatchmaking(opts: MatchmakingOptions) {
|
async startMatchmaking(opts: MatchmakingOptions) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
PlayerSummary,
|
PlayerSummary,
|
||||||
PublicProfile,
|
PublicProfile,
|
||||||
ReportReason,
|
ReportReason,
|
||||||
|
RoomInvite,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
MatchmakingState,
|
MatchmakingState,
|
||||||
RewardResult,
|
RewardResult,
|
||||||
@@ -122,6 +123,11 @@ export interface OnlineService {
|
|||||||
startRoom(roomId: string): Promise<Room>;
|
startRoom(roomId: string): Promise<Room>;
|
||||||
leaveRoom(roomId: string): Promise<void>;
|
leaveRoom(roomId: string): Promise<void>;
|
||||||
onRoom(cb: (room: Room) => void): Unsubscribe;
|
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 ----- */
|
/* ----- matchmaking ----- */
|
||||||
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
|
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
MatchmakingState,
|
MatchmakingState,
|
||||||
RewardResult,
|
RewardResult,
|
||||||
Room,
|
Room,
|
||||||
|
RoomInvite,
|
||||||
ServerGameState,
|
ServerGameState,
|
||||||
ShopItem,
|
ShopItem,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
@@ -35,6 +36,22 @@ import {
|
|||||||
const SERVER = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5005";
|
const SERVER = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5005";
|
||||||
const LS_SESSION = "hokm.session";
|
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
|
* Talks to the .NET SignalR backend for auth, matchmaking, live game state and
|
||||||
* reactions. Everything not yet server-backed (profile, friends, shop, daily,
|
* 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 chatCbs = new Set<(id: string, m: ChatMessage[]) => void>();
|
||||||
private typingCbs = new Set<(fromId: string) => void>();
|
private typingCbs = new Set<(fromId: string) => void>();
|
||||||
private forfeitCbs = new Set<(r: ForfeitRequest | null) => 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;
|
private cachedProfile: UserProfile | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -133,6 +154,18 @@ export class SignalrService implements OnlineService {
|
|||||||
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
||||||
conn.on("typing", (m: { from: string }) =>
|
conn.on("typing", (m: { from: string }) =>
|
||||||
this.typingCbs.forEach((cb) => cb(m.from)));
|
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) =>
|
conn.on("notification", (n: AppNotification) =>
|
||||||
this.notifCbs.forEach((cb) => cb(n)));
|
this.notifCbs.forEach((cb) => cb(n)));
|
||||||
conn.on("profile", (p: UserProfile) =>
|
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); }
|
onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); }
|
||||||
|
|
||||||
createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); }
|
// --- private rooms (server-authoritative, real friend invites) ---
|
||||||
setPartner(roomId: string, friendId: string | null) { return this.mock.setPartner(roomId, friendId); }
|
private mapRoom(r: ServerRoom): Room {
|
||||||
inviteToSeat(roomId: string, seat: 1 | 3, friendId: string) { return this.mock.inviteToSeat(roomId, seat, friendId); }
|
const myId = this.session?.userId;
|
||||||
addBot(roomId: string, seat: 1 | 2 | 3) { return this.mock.addBot(roomId, seat); }
|
return {
|
||||||
clearSeat(roomId: string, seat: 1 | 2 | 3) { return this.mock.clearSeat(roomId, seat); }
|
id: r.id, code: r.code, hostId: r.hostId, status: "open",
|
||||||
startRoom(roomId: string) { return this.mock.startRoom(roomId); }
|
targetScore: r.targetScore, stake: r.stake, ranked: r.ranked,
|
||||||
leaveRoom(roomId: string) { return this.mock.leaveRoom(roomId); }
|
seats: r.seats.map((s) => ({
|
||||||
onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); }
|
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"); }
|
listConversations(): Promise<Conversation[]> { return this.getJson<Conversation[]>("/api/chat"); }
|
||||||
getMessages(id: string): Promise<ChatMessage[]> {
|
getMessages(id: string): Promise<ChatMessage[]> {
|
||||||
|
|||||||
@@ -356,6 +356,14 @@ export interface Room {
|
|||||||
ranked: boolean;
|
ranked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An incoming invitation to someone else's private room. */
|
||||||
|
export interface RoomInvite {
|
||||||
|
roomId: string;
|
||||||
|
code: string;
|
||||||
|
hostName: string;
|
||||||
|
stake: number;
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------- Matchmaking ----------------------------- */
|
/* --------------------------- Matchmaking ----------------------------- */
|
||||||
|
|
||||||
export type MatchmakingPhase =
|
export type MatchmakingPhase =
|
||||||
|
|||||||
Reference in New Issue
Block a user