Synchronise les parties entre les deux devices

This commit is contained in:
2026-04-15 23:40:18 +02:00
parent 3b88b9abe6
commit e0c3a41ccd
11 changed files with 527 additions and 18 deletions

View File

@@ -0,0 +1,89 @@
namespace ChessCubing.Server.Social;
public sealed class CollaborativeMatchCoordinator
{
private readonly object _sync = new();
private readonly Dictionary<string, CollaborativeSessionState> _sessions = new(StringComparer.Ordinal);
public void RegisterSession(PlaySessionResponse session)
{
lock (_sync)
{
_sessions[session.SessionId] = new CollaborativeSessionState(
session.SessionId,
new HashSet<string>(StringComparer.Ordinal)
{
session.WhiteSubject,
session.BlackSubject,
session.InitiatorSubject,
session.RecipientSubject,
},
null,
0);
}
}
public bool CanAccessSession(string sessionId, string subject)
{
lock (_sync)
{
return _sessions.TryGetValue(sessionId, out var session)
&& session.ParticipantSubjects.Contains(subject);
}
}
public CollaborativeMatchStateMessage? GetLatestState(string sessionId, string subject)
{
lock (_sync)
{
if (!_sessions.TryGetValue(sessionId, out var session) ||
!session.ParticipantSubjects.Contains(subject))
{
throw new SocialValidationException("Cette session de partie est introuvable ou n'est pas accessible.");
}
return session.LatestState;
}
}
public CollaborativeMatchStateMessage PublishState(
string sessionId,
string subject,
string? matchJson,
string route)
{
lock (_sync)
{
if (!_sessions.TryGetValue(sessionId, out var session) ||
!session.ParticipantSubjects.Contains(subject))
{
throw new SocialValidationException("Cette session de partie est introuvable ou n'est pas accessible.");
}
var revision = session.Revision + 1;
var message = new CollaborativeMatchStateMessage
{
SessionId = sessionId,
MatchJson = matchJson,
Route = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(),
SenderSubject = subject,
Revision = revision,
UpdatedUtc = DateTime.UtcNow,
};
_sessions[sessionId] = session with
{
Revision = revision,
LatestState = message,
};
return message;
}
}
private sealed record CollaborativeSessionState(
string SessionId,
HashSet<string> ParticipantSubjects,
CollaborativeMatchStateMessage? LatestState,
long Revision);
}

View File

@@ -134,4 +134,19 @@ public sealed class PlaySessionResponse
public DateTime ConfirmedUtc { get; init; }
}
public sealed class CollaborativeMatchStateMessage
{
public string SessionId { get; init; } = string.Empty;
public string? MatchJson { get; init; }
public string Route { get; init; } = "/application.html";
public string SenderSubject { get; init; } = string.Empty;
public long Revision { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed class SocialValidationException(string message) : Exception(message);

View File

@@ -9,12 +9,14 @@ public sealed class SocialHub(
ConnectedUserTracker tracker,
MySqlSocialStore socialStore,
MySqlUserProfileStore profileStore,
PlayInviteCoordinator playInviteCoordinator) : Hub
PlayInviteCoordinator playInviteCoordinator,
CollaborativeMatchCoordinator collaborativeMatchCoordinator) : Hub
{
private readonly ConnectedUserTracker _tracker = tracker;
private readonly MySqlSocialStore _socialStore = socialStore;
private readonly MySqlUserProfileStore _profileStore = profileStore;
private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator;
private readonly CollaborativeMatchCoordinator _collaborativeMatchCoordinator = collaborativeMatchCoordinator;
public override async Task OnConnectedAsync()
{
@@ -99,6 +101,7 @@ public sealed class SocialHub(
if (accept)
{
var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject);
_collaborativeMatchCoordinator.RegisterSession(accepted.Session);
await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject])
.SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted);
return;
@@ -130,6 +133,50 @@ public sealed class SocialHub(
}
}
public async Task<CollaborativeMatchStateMessage?> JoinPlaySession(string sessionId)
{
var siteUser = RequireCurrentUser();
var normalizedSessionId = sessionId.Trim();
try
{
var latestState = _collaborativeMatchCoordinator.GetLatestState(normalizedSessionId, siteUser.Subject);
await Groups.AddToGroupAsync(Context.ConnectionId, BuildSessionGroupName(normalizedSessionId), Context.ConnectionAborted);
return latestState;
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
public async Task LeavePlaySession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return;
}
await Groups.RemoveFromGroupAsync(Context.ConnectionId, BuildSessionGroupName(sessionId.Trim()), Context.ConnectionAborted);
}
public async Task PublishMatchState(string sessionId, string? matchJson, string route)
{
var siteUser = RequireCurrentUser();
var normalizedSessionId = sessionId.Trim();
try
{
var message = _collaborativeMatchCoordinator.PublishState(normalizedSessionId, siteUser.Subject, matchJson, route);
await Clients.GroupExcept(BuildSessionGroupName(normalizedSessionId), [Context.ConnectionId])
.SendAsync("CollaborativeMatchStateUpdated", message, Context.ConnectionAborted);
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
private AuthenticatedSiteUser RequireCurrentUser()
=> AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal())
?? throw new HubException("La session utilisateur est incomplete.");
@@ -165,4 +212,7 @@ public sealed class SocialHub(
IsOnline = isOnline,
});
}
private static string BuildSessionGroupName(string sessionId)
=> $"play-session:{sessionId}";
}