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>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/// <summary>Notify a chat peer that this user is typing (ephemeral, not stored).</summary>
|
||||
public Task Typing(string peerId) =>
|
||||
string.IsNullOrWhiteSpace(peerId)
|
||||
|
||||
Reference in New Issue
Block a user