feat(squad): in-memory squad/lobby state + wire squad functions
Build & Deploy / build (push) Successful in 31s

Adds SquadService (singleton, process-local) tracking squads by the
squadId the client shares across party members, plus a user->squad index
so TryGetCompleteSquadInfo (no squadId in request) can resolve the
caller's squad. Members are lazily registered on any squad call.

- TryGetCompleteSquadInfo: returns the caller's real squad.
- SquadMemberReadyForMatch: joins the squad, records readiness/map,
  returns the squad + whether everyone is ready.
- SquadMemberSelectedMap: records the member's selected map.
- DbUserService.FindByIdAsync: resolve display name for squad members.

Groundwork for the station lobby; assumes the client drives squads via
these calls with a shared squadId (to confirm with a 2-client session).
Real-time SignalR push (unknown event names) deferred; the squad is
carried in responses + TryGetCompleteSquadInfo for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:34:43 +02:00
co-authored by Claude Opus 4.8
parent 922deb8397
commit 0588849e4c
6 changed files with 166 additions and 33 deletions
@@ -1,6 +1,9 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.Squad;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs; using Prospect.Server.Api.Hubs;
@@ -28,12 +31,16 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
private readonly ILogger<SquadMemberReadyForMatchFunction> _logger; private readonly ILogger<SquadMemberReadyForMatchFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHubContext<CycleHub> _hubContext; private readonly IHubContext<CycleHub> _hubContext;
private readonly SquadService _squadService;
private readonly DbUserService _userService;
public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext) public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext, SquadService squadService, DbUserService userService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger; _logger = logger;
_hubContext = hubContext; _hubContext = hubContext;
_squadService = squadService;
_userService = userService;
} }
public async Task<SquadMemberReadyForMatchResponse> ExecuteAsync(SquadMemberReadyForMatchRequest request) public async Task<SquadMemberReadyForMatchResponse> ExecuteAsync(SquadMemberReadyForMatchRequest request)
@@ -44,27 +51,25 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var squadId = string.IsNullOrEmpty(request.SquadID) ? userId : request.SquadID;
// Register the caller into the squad (party is formed client-side; each member's client
// calls this with the shared squadId) and record their readiness / selected map.
var displayName = (await _userService.FindByIdAsync(userId))?.DisplayName ?? "";
_squadService.JoinOrCreate(squadId, userId, displayName);
var ready = request.matchmakingSettings?.isReadyForMatch ?? false;
_squadService.SetReady(squadId, userId, ready, request.matchmakingSettings?.selectedMapName);
var squad = _squadService.Get(squadId)!;
var isSquadReady = _squadService.IsSquadReady(squadId);
_logger.LogInformation("SquadMemberReadyForMatch: {User} ready={Ready} in squad {Squad} ({Count} member(s), squadReady={SquadReady})",
userId, ready, squadId, squad.Members.Count, isSquadReady);
return new SquadMemberReadyForMatchResponse return new SquadMemberReadyForMatchResponse
{ {
Result = 0, // EYSquadActionResult::OK Result = 0, // EYSquadActionResult::OK
Squad = new FYPlayFabSquad { Squad = _squadService.ToClientSquad(squad),
SquadID = "100", // TODO IsSquadReadyForMatch = isSquadReady,
// Members = [
// new FYPlayFabSquadMember {
// Profile = new FYPlayFabPlayerProfile {
// PlayerId = userId,
// },
// onlineState = 0, // EYUserState::IN_STATION
// matchmakingSettings = new FYUserMatchmakingSettings {
// isReadyForMatch = true,
// isSecretLeader = true,
// selectedMapName = "Map01",
// }
// }
// ],
},
IsSquadReadyForMatch = true,
}; };
} }
} }
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Squad;
public class SquadMemberSelectedMapRequest public class SquadMemberSelectedMapRequest
{ {
[JsonPropertyName("squadId")] [JsonPropertyName("squadId")]
@@ -17,15 +20,26 @@ public class SquadMemberSelectedMapResponse
public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse> public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse>
{ {
private readonly ILogger<SquadMemberSelectedMapFunction> _logger; private readonly ILogger<SquadMemberSelectedMapFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger) public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor;
_squadService = squadService;
} }
public async Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request) public Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
{ {
return new SquadMemberSelectedMapResponse var context = _httpContextAccessor.HttpContext;
{}; if (context != null && !string.IsNullOrEmpty(request.SquadID))
{
var userId = context.User.FindAuthUserId();
_squadService.JoinOrCreate(request.SquadID, userId, "");
_squadService.SetMap(request.SquadID, userId, request.SelectedMapName ?? "");
_logger.LogInformation("SquadMemberSelectedMap: {User} -> {Map} (squad {Squad})", userId, request.SelectedMapName, request.SquadID);
}
return Task.FromResult(new SquadMemberSelectedMapResponse { });
} }
} }
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Squad;
public class TryGetCompleteSquadInfoRequest public class TryGetCompleteSquadInfoRequest
{ {
@@ -51,26 +54,30 @@ public class TryGetCompleteSquadInfoFunction : ICloudScriptFunction<TryGetComple
{ {
private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger; private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor) public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger; _logger = logger;
_squadService = squadService;
} }
public async Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request) public Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
// var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var squad = _squadService.GetByUser(userId);
return new FYPlayFabSquad if (squad == null)
{ {
// SquadID = "100", // Not in a squad yet — return an empty squad (same as the previous stub).
// Members = [], return Task.FromResult(new FYPlayFabSquad());
}; }
_logger.LogInformation("TryGetCompleteSquadInfo: user {User} in squad {Squad} ({Count} member(s))", userId, squad.SquadId, squad.Members.Count);
return Task.FromResult(_squadService.ToClientSquad(squad));
} }
} }
@@ -18,6 +18,12 @@ public class DbUserService : BaseDbService<PlayFabUser>
auth.Key == key)).FirstOrDefaultAsync(); auth.Key == key)).FirstOrDefaultAsync();
} }
// Resolve a player by its PlayFab id (used to display squad member names).
public async Task<PlayFabUser> FindByIdAsync(string id)
{
return await Collection.Find(user => user.Id == id).FirstOrDefaultAsync();
}
private async Task<PlayFabUser> CreateAsync(PlayFabUserAuthType type, string key) private async Task<PlayFabUser> CreateAsync(PlayFabUserAuthType type, string key)
{ {
var user = new PlayFabUser var user = new PlayFabUser
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Services.Squad;
// In-memory squad/lobby state. Squads are keyed by the squadId the client provides on the
// SquadMember* calls (the client forms the party and shares a squadId; each member's client
// then calls the backend with it). A member is (lazily) registered into that squad on any
// squad call, and the current squad for a user is tracked so TryGetCompleteSquadInfo — which
// carries no squadId — can still return it. State is process-local (fine for a small server).
public class SquadMemberInfo
{
public string UserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public int OnlineState { get; set; }
public bool IsReady { get; set; }
public string SelectedMap { get; set; } = "";
public bool IsLeader { get; set; }
}
public class SquadInfo
{
public string SquadId { get; set; } = "";
public ConcurrentDictionary<string, SquadMemberInfo> Members { get; } = new();
}
public class SquadService
{
private readonly ConcurrentDictionary<string, SquadInfo> _squads = new();
private readonly ConcurrentDictionary<string, string> _userToSquad = new();
public SquadInfo JoinOrCreate(string squadId, string userId, string displayName)
{
var squad = _squads.GetOrAdd(squadId, id => new SquadInfo { SquadId = id });
var isFirst = squad.Members.IsEmpty;
var member = squad.Members.GetOrAdd(userId, uid => new SquadMemberInfo { UserId = uid, IsLeader = isFirst });
if (!string.IsNullOrEmpty(displayName)) member.DisplayName = displayName;
_userToSquad[userId] = squadId;
return squad;
}
public SquadInfo? GetByUser(string userId)
=> _userToSquad.TryGetValue(userId, out var sid) && _squads.TryGetValue(sid, out var sq) ? sq : null;
public SquadInfo? Get(string squadId)
=> _squads.TryGetValue(squadId, out var sq) ? sq : null;
public void SetReady(string squadId, string userId, bool ready, string? selectedMap)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
{
m.IsReady = ready;
if (!string.IsNullOrEmpty(selectedMap)) m.SelectedMap = selectedMap;
}
}
public void SetMap(string squadId, string userId, string map)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
m.SelectedMap = map;
}
public bool IsSquadReady(string squadId)
=> _squads.TryGetValue(squadId, out var sq) && !sq.Members.IsEmpty && sq.Members.Values.All(m => m.IsReady);
public void Leave(string userId)
{
if (_userToSquad.TryRemove(userId, out var sid) && _squads.TryGetValue(sid, out var sq))
{
sq.Members.TryRemove(userId, out _);
if (sq.Members.IsEmpty) _squads.TryRemove(sid, out _);
}
}
// Build the client-facing squad object (models live in the global namespace).
public FYPlayFabSquad ToClientSquad(SquadInfo squad)
{
return new FYPlayFabSquad
{
SquadID = squad.SquadId,
Members = squad.Members.Values.Select(m => new FYPlayFabSquadMember
{
Profile = new FYPlayFabPlayerProfile
{
PlayerId = m.UserId,
DisplayName = m.DisplayName,
AvatarUrl = "",
},
onlineState = m.OnlineState,
matchmakingSettings = new FYUserMatchmakingSettings
{
isReadyForMatch = m.IsReady,
selectedMapName = m.SelectedMap,
isSecretLeader = m.IsLeader,
},
mapRowNamesUnlocked = Array.Empty<string>(),
}).ToArray(),
};
}
}
+2
View File
@@ -35,6 +35,8 @@ public class Startup
services.AddSingleton<DbUserDataService>(); services.AddSingleton<DbUserDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Squad.SquadService>();
services.AddHostedService<QosService>(); services.AddHostedService<QosService>();
services.AddSingleton<QosServer>(); services.AddSingleton<QosServer>();