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}";
}