fix(matchmaking): deploy signal targets only the deploying player
Build & Deploy / build (push) Successful in 32s

EnterMatchmakingMatch broadcast OnSquadMatchmakingSuccess to Clients.All,
so one player deploying pulled every connected player into the match.
Map each player to their SignalR connection (GetSignalRConnection tags the
URL with uid; CycleHub records it via a SignalRConnectionRegistry) and
send the deploy signal only to that connection (broadcast fallback if
unmapped). Also enable m_automaticImportPlatformFriends (Steam friends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 17:56:22 +02:00
co-authored by Claude Opus 4.8
parent 8057fcae47
commit 55a9571958
6 changed files with 77 additions and 10 deletions
+22 -3
View File
@@ -1,12 +1,31 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace Prospect.Server.Api.Hubs; namespace Prospect.Server.Api.Hubs;
public class CycleHub : Hub public class CycleHub : Hub
{ {
private readonly SignalRConnectionRegistry _registry;
public CycleHub(SignalRConnectionRegistry registry)
{
_registry = registry;
}
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
Console.WriteLine("Connected {0}", Context.ConnectionId); // The client connects with the SignalR URL returned by GetSignalRConnection, which
// carries a `uid` query param — use it to map this connection to the player so
// server-initiated messages can target a single player instead of everyone.
var uid = Context.GetHttpContext()?.Request.Query["uid"].ToString();
if (!string.IsNullOrEmpty(uid))
_registry.Add(uid, Context.ConnectionId);
Console.WriteLine("Connected {0} (uid={1})", Context.ConnectionId, uid);
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_registry.Remove(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,29 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Hubs;
// Maps players to their live SignalR connection so server-initiated messages (e.g. the
// matchmaking-success "travel to match" signal) can target a single player instead of being
// broadcast to everyone. The client's SignalR URL carries a `uid` query param (added by
// GetSignalRConnection); CycleHub records it on connect.
public class SignalRConnectionRegistry
{
private readonly ConcurrentDictionary<string, string> _userToConn = new();
private readonly ConcurrentDictionary<string, string> _connToUser = new();
public void Add(string userId, string connectionId)
{
if (string.IsNullOrEmpty(userId)) return;
_userToConn[userId] = connectionId;
_connToUser[connectionId] = userId;
}
public void Remove(string connectionId)
{
if (_connToUser.TryRemove(connectionId, out var userId))
_userToConn.TryRemove(userId, out _);
}
public string? GetConnection(string userId)
=> _userToConn.TryGetValue(userId, out var c) ? c : null;
}
@@ -24,13 +24,15 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
private readonly IHubContext<CycleHub> _hubContext; private readonly IHubContext<CycleHub> _hubContext;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService; private readonly TitleDataService _titleDataService;
private readonly SignalRConnectionRegistry _registry;
public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService, SignalRConnectionRegistry registry)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_hubContext = hubContext; _hubContext = hubContext;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService; _titleDataService = titleDataService;
_registry = registry;
} }
public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request) public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request)
@@ -94,11 +96,20 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
} }
); );
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", new OnSquadMatchmakingSuccessMessage { // Send the "matchmaking succeeded / travel to match" signal only to the deploying
// player (via their mapped SignalR connection) — broadcasting to Clients.All made
// every connected player travel too. Fall back to broadcast only if the connection
// isn't mapped yet.
var deployMessage = new OnSquadMatchmakingSuccessMessage {
Success = true, Success = true,
SessionID = request.MapName, // TODO: Need to implement TryGetCompleteSquadInfo and pass squad info SessionID = request.MapName, // TODO: pass real squad session info
SquadID = request.SquadId, SquadID = request.SquadId,
}); };
var connectionId = _registry.GetConnection(userId);
if (connectionId != null)
await _hubContext.Clients.Client(connectionId).SendAsync("OnSquadMatchmakingSuccess", deployMessage);
else
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", deployMessage);
return new FYEnterMatchmakingResult return new FYEnterMatchmakingResult
{ {
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config; using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -43,7 +44,13 @@ public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection,
catch { /* keep the configured URL on any parsing issue */ } catch { /* keep the configured URL on any parsing issue */ }
} }
_logger.LogInformation("GetSignalRConnection: remoteIp={Remote} lan={Lan} -> {Url}", remote, lan, url); // Tag the connection with the player id so the hub can map connection -> user and
// target server messages (deploy signal) instead of broadcasting to everyone.
var userId = _httpContextAccessor.HttpContext?.User.FindAuthUserId();
if (!string.IsNullOrEmpty(userId))
url += (url.Contains('?') ? "&" : "?") + "uid=" + Uri.EscapeDataString(userId);
_logger.LogInformation("GetSignalRConnection: remoteIp={Remote} lan={Lan} uid={Uid} -> {Url}", remote, lan, userId, url);
return Task.FromResult(new FYGetSignalRConnectionResult return Task.FromResult(new FYGetSignalRConnectionResult
{ {
File diff suppressed because one or more lines are too long
+1
View File
@@ -36,6 +36,7 @@ public class Startup
services.AddSingleton<DbUserDataService>(); services.AddSingleton<DbUserDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Squad.SquadService>(); services.AddSingleton<Prospect.Server.Api.Services.Squad.SquadService>();
services.AddSingleton<Prospect.Server.Api.Hubs.SignalRConnectionRegistry>();
services.AddHostedService<QosService>(); services.AddHostedService<QosService>();
services.AddSingleton<QosServer>(); services.AddSingleton<QosServer>();