From e0c3a41ccdca14b6e9e7c961e04f5c0c8bd6aa67 Mon Sep 17 00:00:00 2001 From: Christophe Date: Wed, 15 Apr 2026 23:40:18 +0200 Subject: [PATCH] Synchronise les parties entre les deux devices --- ChessCubing.App/Models/MatchModels.cs | 3 + ChessCubing.App/Models/Social/SocialModels.cs | 32 ++++ ChessCubing.App/Pages/ApplicationPage.razor | 61 ++++++- ChessCubing.App/Pages/ChronoPage.razor | 59 ++++++ ChessCubing.App/Pages/CubePage.razor | 56 ++++++ ChessCubing.App/Services/MatchStore.cs | 7 + .../Services/SocialRealtimeService.cs | 170 ++++++++++++++++-- ChessCubing.Server/Program.cs | 1 + .../Social/CollaborativeMatchCoordinator.cs | 89 +++++++++ ChessCubing.Server/Social/SocialContracts.cs | 15 ++ ChessCubing.Server/Social/SocialHub.cs | 52 +++++- 11 files changed, 527 insertions(+), 18 deletions(-) create mode 100644 ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs diff --git a/ChessCubing.App/Models/MatchModels.cs b/ChessCubing.App/Models/MatchModels.cs index 8935694..f3d393b 100644 --- a/ChessCubing.App/Models/MatchModels.cs +++ b/ChessCubing.App/Models/MatchModels.cs @@ -47,6 +47,9 @@ public sealed class MatchState [JsonPropertyName("schemaVersion")] public int SchemaVersion { get; set; } = 3; + [JsonPropertyName("collaborationSessionId")] + public string? CollaborationSessionId { get; set; } + [JsonPropertyName("config")] public MatchConfig Config { get; set; } = new(); diff --git a/ChessCubing.App/Models/Social/SocialModels.cs b/ChessCubing.App/Models/Social/SocialModels.cs index 2d3a15c..a06fd39 100644 --- a/ChessCubing.App/Models/Social/SocialModels.cs +++ b/ChessCubing.App/Models/Social/SocialModels.cs @@ -1,3 +1,5 @@ +using ChessCubing.App.Models; + namespace ChessCubing.App.Models.Social; public sealed class SocialOverviewResponse @@ -133,3 +135,33 @@ 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 CollaborativeMatchSnapshot +{ + public string SessionId { get; init; } = string.Empty; + + public MatchState? Match { 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; } +} diff --git a/ChessCubing.App/Pages/ApplicationPage.razor b/ChessCubing.App/Pages/ApplicationPage.razor index f6f877d..c366924 100644 --- a/ChessCubing.App/Pages/ApplicationPage.razor +++ b/ChessCubing.App/Pages/ApplicationPage.razor @@ -383,6 +383,8 @@ private bool IsAuthenticated; private bool IsSocialLoading; private int _knownSocialVersion; + private long _knownCollaborativeRevision; + private string? _appliedActiveSessionId; private string? ConnectedPlayerName; private string? SetupError; private string? SocialLoadError; @@ -463,6 +465,11 @@ } await Store.EnsureLoadedAsync(); + if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId)) + { + await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId); + } + _ready = true; StateHasChanged(); } @@ -476,6 +483,7 @@ private async Task HandleRealtimeChangedAsync() { ApplyAcceptedPlaySession(); + await ApplyCollaborativeSyncAsync(); if (IsAuthenticated && _knownSocialVersion != Realtime.SocialVersion) { @@ -539,6 +547,7 @@ } ApplyAcceptedPlaySession(); + await ApplyCollaborativeSyncAsync(); await InvokeAsync(StateHasChanged); } @@ -598,11 +607,18 @@ await Store.EnsureLoadedAsync(); var match = MatchEngine.CreateMatch(Form.ToMatchConfig()); + if (!string.IsNullOrWhiteSpace(Realtime.ActivePlaySession?.SessionId)) + { + match.CollaborationSessionId = Realtime.ActivePlaySession.SessionId; + await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId); + } + Store.SetCurrent(match); try { await Store.SaveAsync(); + await Realtime.PublishMatchStateAsync(match, "/chrono.html"); } catch { @@ -699,7 +715,14 @@ private async Task ClearMatchAsync() { + var sessionId = CurrentMatch?.CollaborationSessionId; await Store.ClearAsync(); + + if (!string.IsNullOrWhiteSpace(sessionId)) + { + await Realtime.PublishMatchStateAsync(null, "/application.html"); + } + StateHasChanged(); } @@ -727,8 +750,8 @@ private void ApplyAcceptedPlaySession() { - var session = Realtime.TakeAcceptedPlaySession(); - if (session is null) + var session = Realtime.ActivePlaySession; + if (session is null || string.Equals(_appliedActiveSessionId, session.SessionId, StringComparison.Ordinal)) { return; } @@ -736,6 +759,7 @@ Form.WhiteName = session.WhiteName; Form.BlackName = session.BlackName; SetupError = null; + _appliedActiveSessionId = session.SessionId; } private void AssignConnectedPlayerToWhite() @@ -784,6 +808,32 @@ : null; } + private async Task ApplyCollaborativeSyncAsync() + { + var snapshot = Realtime.CollaborativeSnapshot; + if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision) + { + return; + } + + _knownCollaborativeRevision = snapshot.Revision; + Store.ReplaceCurrent(snapshot.Match); + await Store.SaveAsync(); + + if (snapshot.Match is not null) + { + Form.WhiteName = snapshot.Match.Config.WhiteName; + Form.BlackName = snapshot.Match.Config.BlackName; + SetupError = null; + } + + var route = NormalizeRoute(snapshot.Route); + if (!string.Equals(route, "/application.html", StringComparison.OrdinalIgnoreCase)) + { + Navigation.NavigateTo(route, replace: true); + } + } + private bool ResolveOnlineStatus(string subject, bool fallbackStatus) => Realtime.GetKnownOnlineStatus(subject) ?? fallbackStatus; @@ -813,12 +863,19 @@ MatchEngine.SanitizeText(right) ?? string.Empty, StringComparison.OrdinalIgnoreCase); + private static string NormalizeRoute(string? route) + { + var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(); + return normalized.StartsWith('/') ? normalized : $"/{normalized}"; + } + private void ResetSocialState() { SocialOverview = null; SocialLoadError = null; InviteActionError = null; _knownSocialVersion = Realtime.SocialVersion; + _knownCollaborativeRevision = 0; } private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user) diff --git a/ChessCubing.App/Pages/ChronoPage.razor b/ChessCubing.App/Pages/ChronoPage.razor index 7bf2658..ee354b4 100644 --- a/ChessCubing.App/Pages/ChronoPage.razor +++ b/ChessCubing.App/Pages/ChronoPage.razor @@ -3,6 +3,7 @@ @implements IAsyncDisposable @inject MatchStore Store @inject NavigationManager Navigation +@inject SocialRealtimeService Realtime ChessCubing Arena | Phase Chrono @@ -156,6 +157,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh private CancellationTokenSource? _tickerCancellation; private bool _ready; private bool ShowArbiterModal; + private long _knownCollaborativeRevision; private MatchState? Match => Store.Current; @@ -171,7 +173,16 @@ else if (Match is not null && summary is not null && blackZone is not null && wh return; } + Realtime.Changed += HandleRealtimeChanged; + await Realtime.EnsureStartedAsync(); await Store.EnsureLoadedAsync(); + + if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId)) + { + await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId); + await ApplyCollaborativeSyncAsync(); + } + _ready = true; if (Match is null) @@ -200,9 +211,19 @@ else if (Match is not null && summary is not null && blackZone is not null && wh _tickerCancellation.Dispose(); } + Realtime.Changed -= HandleRealtimeChanged; await Store.FlushIfDueAsync(0); } + private void HandleRealtimeChanged() + => _ = InvokeAsync(HandleRealtimeChangedAsync); + + private async Task HandleRealtimeChangedAsync() + { + await ApplyCollaborativeSyncAsync(); + StateHasChanged(); + } + private async Task RunTickerAsync(CancellationToken cancellationToken) { try @@ -385,7 +406,14 @@ else if (Match is not null && summary is not null && blackZone is not null && wh private async Task ResetMatchAsync() { + var sessionId = Match?.CollaborationSessionId; await Store.ClearAsync(); + + if (!string.IsNullOrWhiteSpace(sessionId)) + { + await Realtime.PublishMatchStateAsync(null, "/application.html"); + } + Navigation.NavigateTo("/application.html", replace: true); } @@ -394,6 +422,12 @@ else if (Match is not null && summary is not null && blackZone is not null && wh Store.MarkDirty(); await Store.SaveAsync(); + var route = Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube + ? "/cube.html" + : "/chrono.html"; + + await Realtime.PublishMatchStateAsync(Match, route); + if (Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube) { Navigation.NavigateTo("/cube.html", replace: true); @@ -574,6 +608,31 @@ else if (Match is not null && summary is not null && blackZone is not null && wh private static string BoolString(bool value) => value ? "true" : "false"; + private async Task ApplyCollaborativeSyncAsync() + { + var snapshot = Realtime.CollaborativeSnapshot; + if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision) + { + return; + } + + _knownCollaborativeRevision = snapshot.Revision; + Store.ReplaceCurrent(snapshot.Match); + await Store.SaveAsync(); + + var route = NormalizeRoute(snapshot.Route); + if (!string.Equals(route, "/chrono.html", StringComparison.OrdinalIgnoreCase)) + { + Navigation.NavigateTo(route, replace: true); + } + } + + private static string NormalizeRoute(string? route) + { + var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(); + return normalized.StartsWith('/') ? normalized : $"/{normalized}"; + } + private sealed record ChronoSummaryView( string Title, string Subtitle, diff --git a/ChessCubing.App/Pages/CubePage.razor b/ChessCubing.App/Pages/CubePage.razor index 343d935..e1eae61 100644 --- a/ChessCubing.App/Pages/CubePage.razor +++ b/ChessCubing.App/Pages/CubePage.razor @@ -4,6 +4,7 @@ @inject BrowserBridge Browser @inject MatchStore Store @inject NavigationManager Navigation +@inject SocialRealtimeService Realtime ChessCubing Arena | Phase Cube @@ -207,6 +208,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh private bool ShowHelpModal; private bool ShowResultModal; private string? _resultModalKey; + private long _knownCollaborativeRevision; private MatchState? Match => Store.Current; @@ -217,7 +219,16 @@ else if (Match is not null && summary is not null && blackZone is not null && wh return; } + Realtime.Changed += HandleRealtimeChanged; + await Realtime.EnsureStartedAsync(); await Store.EnsureLoadedAsync(); + + if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId)) + { + await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId); + await ApplyCollaborativeSyncAsync(); + } + _ready = true; if (Match is null) @@ -247,9 +258,20 @@ else if (Match is not null && summary is not null && blackZone is not null && wh _tickerCancellation.Dispose(); } + Realtime.Changed -= HandleRealtimeChanged; await Store.FlushIfDueAsync(0); } + private void HandleRealtimeChanged() + => _ = InvokeAsync(HandleRealtimeChangedAsync); + + private async Task HandleRealtimeChangedAsync() + { + await ApplyCollaborativeSyncAsync(); + UpdateResultModalState(); + StateHasChanged(); + } + private async Task RunTickerAsync(CancellationToken cancellationToken) { try @@ -418,6 +440,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh MatchEngine.ApplyCubeOutcome(match); Store.MarkDirty(); await Store.SaveAsync(); + await Realtime.PublishMatchStateAsync(match, "/chrono.html"); Navigation.NavigateTo("/chrono.html"); } @@ -438,7 +461,14 @@ else if (Match is not null && summary is not null && blackZone is not null && wh private async Task ResetMatchAsync() { + var sessionId = Match?.CollaborationSessionId; await Store.ClearAsync(); + + if (!string.IsNullOrWhiteSpace(sessionId)) + { + await Realtime.PublishMatchStateAsync(null, "/application.html"); + } + Navigation.NavigateTo("/application.html", replace: true); } @@ -446,6 +476,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh { Store.MarkDirty(); await Store.SaveAsync(); + await Realtime.PublishMatchStateAsync(Match, "/cube.html"); UpdateResultModalState(); StateHasChanged(); } @@ -773,6 +804,31 @@ else if (Match is not null && summary is not null && blackZone is not null && wh private static string BoolString(bool value) => value ? "true" : "false"; + private async Task ApplyCollaborativeSyncAsync() + { + var snapshot = Realtime.CollaborativeSnapshot; + if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision) + { + return; + } + + _knownCollaborativeRevision = snapshot.Revision; + Store.ReplaceCurrent(snapshot.Match); + await Store.SaveAsync(); + + var route = NormalizeRoute(snapshot.Route); + if (!string.Equals(route, "/cube.html", StringComparison.OrdinalIgnoreCase)) + { + Navigation.NavigateTo(route, replace: true); + } + } + + private static string NormalizeRoute(string? route) + { + var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(); + return normalized.StartsWith('/') ? normalized : $"/{normalized}"; + } + private sealed class CubeHoldState { public bool Armed { get; set; } diff --git a/ChessCubing.App/Services/MatchStore.cs b/ChessCubing.App/Services/MatchStore.cs index f5b6682..e4324b5 100644 --- a/ChessCubing.App/Services/MatchStore.cs +++ b/ChessCubing.App/Services/MatchStore.cs @@ -59,6 +59,13 @@ public sealed class MatchStore(BrowserBridge browser, UserSession userSession) MarkDirty(); } + public void ReplaceCurrent(MatchState? match) + { + Current = match; + IsLoaded = true; + _dirty = false; + } + public void MarkDirty() => _dirty = true; diff --git a/ChessCubing.App/Services/SocialRealtimeService.cs b/ChessCubing.App/Services/SocialRealtimeService.cs index cff3fb9..011e16e 100644 --- a/ChessCubing.App/Services/SocialRealtimeService.cs +++ b/ChessCubing.App/Services/SocialRealtimeService.cs @@ -1,4 +1,7 @@ using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using ChessCubing.App.Models; using ChessCubing.App.Models.Social; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; @@ -10,17 +13,24 @@ public sealed class SocialRealtimeService( NavigationManager navigation, AuthenticationStateProvider authenticationStateProvider) : IAsyncDisposable { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + private readonly NavigationManager _navigation = navigation; private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider; private readonly SemaphoreSlim _gate = new(1, 1); private HubConnection? _hubConnection; private string? _currentSubject; + private string? _joinedPlaySessionId; private HashSet _knownPresenceSubjects = new(StringComparer.Ordinal); private HashSet _onlineSubjects = new(StringComparer.Ordinal); private PlayInviteMessage? _incomingPlayInvite; private PlayInviteMessage? _outgoingPlayInvite; private PlaySessionResponse? _activePlaySession; + private CollaborativeMatchSnapshot? _collaborativeSnapshot; private string? _lastInviteNotice; private int _socialVersion; private bool _started; @@ -31,6 +41,10 @@ public sealed class SocialRealtimeService( public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite; + public PlaySessionResponse? ActivePlaySession => _activePlaySession; + + public CollaborativeMatchSnapshot? CollaborativeSnapshot => _collaborativeSnapshot; + public string? LastInviteNotice => _lastInviteNotice; public int SocialVersion => _socialVersion; @@ -63,6 +77,43 @@ public sealed class SocialRealtimeService( : null; } + public async Task EnsureJoinedPlaySessionAsync(string? sessionId) + { + var normalizedSessionId = sessionId?.Trim(); + if (string.IsNullOrWhiteSpace(normalizedSessionId)) + { + return; + } + + await EnsureStartedAsync(); + await JoinPlaySessionCoreAsync(normalizedSessionId); + } + + public async Task PublishMatchStateAsync(MatchState? match, string route) + { + var sessionId = match?.CollaborationSessionId + ?? _activePlaySession?.SessionId + ?? _joinedPlaySessionId; + + if (string.IsNullOrWhiteSpace(sessionId)) + { + return; + } + + await EnsureJoinedPlaySessionAsync(sessionId); + + if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected) + { + throw new InvalidOperationException("La connexion temps reel n'est pas prete."); + } + + var payload = match is null + ? null + : JsonSerializer.Serialize(match, JsonOptions); + + await _hubConnection.InvokeAsync("PublishMatchState", sessionId, payload, route); + } + public async Task SendPlayInviteAsync(string recipientSubject, string recipientColor) { _lastInviteNotice = null; @@ -105,13 +156,6 @@ public sealed class SocialRealtimeService( await _hubConnection.InvokeAsync("CancelPlayInvite", inviteId); } - public PlaySessionResponse? TakeAcceptedPlaySession() - { - var session = _activePlaySession; - _activePlaySession = null; - return session; - } - public void ClearInviteNotice() { if (string.IsNullOrWhiteSpace(_lastInviteNotice)) @@ -154,10 +198,11 @@ public sealed class SocialRealtimeService( var authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); var user = authState.User; var subject = ResolveSubject(user); + var preserveSessionState = string.Equals(subject, _currentSubject, StringComparison.Ordinal); if (string.IsNullOrWhiteSpace(subject)) { - await DisconnectUnsafeAsync(); + await DisconnectUnsafeAsync(clearSessionState: true); return; } @@ -168,12 +213,17 @@ public sealed class SocialRealtimeService( return; } - await DisconnectUnsafeAsync(); + await DisconnectUnsafeAsync(clearSessionState: !preserveSessionState); _currentSubject = subject; _hubConnection = BuildHubConnection(); RegisterHandlers(_hubConnection); await _hubConnection.StartAsync(); + + if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId)) + { + await JoinPlaySessionCoreAsync(_joinedPlaySessionId); + } } finally { @@ -241,18 +291,31 @@ public sealed class SocialRealtimeService( RaiseChanged(); }); - connection.On("PlayInviteAccepted", session => + connection.On("CollaborativeMatchStateUpdated", message => + { + ApplyCollaborativeState(message); + RaiseChanged(); + }); + + connection.On("PlayInviteAccepted", async session => { _incomingPlayInvite = null; _outgoingPlayInvite = null; _activePlaySession = session; - _lastInviteNotice = "La partie est confirmee. Les noms ont ete pre-remplis dans l'application."; + _lastInviteNotice = "La partie est confirmee. Les deux ecrans resteront synchronises pendant le match."; + await JoinPlaySessionCoreAsync(session.SessionId); RaiseChanged(); }); connection.Reconnected += async _ => { await RequestPresenceSnapshotAsync(); + + if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId)) + { + await JoinPlaySessionCoreAsync(_joinedPlaySessionId); + } + RaiseChanged(); }; @@ -282,6 +345,39 @@ public sealed class SocialRealtimeService( } } + private async Task JoinPlaySessionCoreAsync(string sessionId) + { + if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected) + { + return; + } + + if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId) && + !string.Equals(_joinedPlaySessionId, sessionId, StringComparison.Ordinal)) + { + try + { + await _hubConnection.InvokeAsync("LeavePlaySession", _joinedPlaySessionId); + } + catch + { + // La prochaine connexion recreera les groupes si besoin. + } + } + + var latestState = await _hubConnection.InvokeAsync("JoinPlaySession", sessionId); + _joinedPlaySessionId = sessionId; + + if (latestState is not null) + { + ApplyCollaborativeState(latestState); + } + else if (_collaborativeSnapshot?.SessionId != sessionId) + { + _collaborativeSnapshot = null; + } + } + private void ApplyInvite(PlayInviteMessage invite) { if (string.IsNullOrWhiteSpace(_currentSubject)) @@ -300,15 +396,59 @@ public sealed class SocialRealtimeService( } } - private async Task DisconnectUnsafeAsync() + private void ApplyCollaborativeState(CollaborativeMatchStateMessage message) { - _currentSubject = null; + if (_collaborativeSnapshot is not null && + string.Equals(_collaborativeSnapshot.SessionId, message.SessionId, StringComparison.Ordinal) && + message.Revision <= _collaborativeSnapshot.Revision) + { + return; + } + + MatchState? match = null; + if (!string.IsNullOrWhiteSpace(message.MatchJson)) + { + try + { + match = JsonSerializer.Deserialize(message.MatchJson, JsonOptions); + if (match is not null) + { + match.CollaborationSessionId = message.SessionId; + MatchEngine.NormalizeRecoveredMatch(match); + } + } + catch + { + match = null; + } + } + + _collaborativeSnapshot = new CollaborativeMatchSnapshot + { + SessionId = message.SessionId, + Match = match, + Route = string.IsNullOrWhiteSpace(message.Route) ? "/application.html" : message.Route, + SenderSubject = message.SenderSubject, + Revision = message.Revision, + UpdatedUtc = message.UpdatedUtc, + }; + } + + private async Task DisconnectUnsafeAsync(bool clearSessionState) + { + _currentSubject = clearSessionState ? null : _currentSubject; _knownPresenceSubjects.Clear(); _onlineSubjects.Clear(); _incomingPlayInvite = null; _outgoingPlayInvite = null; - _activePlaySession = null; - _lastInviteNotice = null; + + if (clearSessionState) + { + _activePlaySession = null; + _joinedPlaySessionId = null; + _collaborativeSnapshot = null; + _lastInviteNotice = null; + } if (_hubConnection is not null) { diff --git a/ChessCubing.Server/Program.cs b/ChessCubing.Server/Program.cs index 50688ad..d5dd9ff 100644 --- a/ChessCubing.Server/Program.cs +++ b/ChessCubing.Server/Program.cs @@ -75,6 +75,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs b/ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs new file mode 100644 index 0000000..d16540d --- /dev/null +++ b/ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs @@ -0,0 +1,89 @@ +namespace ChessCubing.Server.Social; + +public sealed class CollaborativeMatchCoordinator +{ + private readonly object _sync = new(); + private readonly Dictionary _sessions = new(StringComparer.Ordinal); + + public void RegisterSession(PlaySessionResponse session) + { + lock (_sync) + { + _sessions[session.SessionId] = new CollaborativeSessionState( + session.SessionId, + new HashSet(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 ParticipantSubjects, + CollaborativeMatchStateMessage? LatestState, + long Revision); +} diff --git a/ChessCubing.Server/Social/SocialContracts.cs b/ChessCubing.Server/Social/SocialContracts.cs index 6f163cf..584da3d 100644 --- a/ChessCubing.Server/Social/SocialContracts.cs +++ b/ChessCubing.Server/Social/SocialContracts.cs @@ -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); diff --git a/ChessCubing.Server/Social/SocialHub.cs b/ChessCubing.Server/Social/SocialHub.cs index 8117e0e..a81c8fd 100644 --- a/ChessCubing.Server/Social/SocialHub.cs +++ b/ChessCubing.Server/Social/SocialHub.cs @@ -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 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}"; }