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

@@ -47,6 +47,9 @@ public sealed class MatchState
[JsonPropertyName("schemaVersion")] [JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; set; } = 3; public int SchemaVersion { get; set; } = 3;
[JsonPropertyName("collaborationSessionId")]
public string? CollaborationSessionId { get; set; }
[JsonPropertyName("config")] [JsonPropertyName("config")]
public MatchConfig Config { get; set; } = new(); public MatchConfig Config { get; set; } = new();

View File

@@ -1,3 +1,5 @@
using ChessCubing.App.Models;
namespace ChessCubing.App.Models.Social; namespace ChessCubing.App.Models.Social;
public sealed class SocialOverviewResponse public sealed class SocialOverviewResponse
@@ -133,3 +135,33 @@ public sealed class PlaySessionResponse
public DateTime ConfirmedUtc { get; init; } 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; }
}

View File

@@ -383,6 +383,8 @@
private bool IsAuthenticated; private bool IsAuthenticated;
private bool IsSocialLoading; private bool IsSocialLoading;
private int _knownSocialVersion; private int _knownSocialVersion;
private long _knownCollaborativeRevision;
private string? _appliedActiveSessionId;
private string? ConnectedPlayerName; private string? ConnectedPlayerName;
private string? SetupError; private string? SetupError;
private string? SocialLoadError; private string? SocialLoadError;
@@ -463,6 +465,11 @@
} }
await Store.EnsureLoadedAsync(); await Store.EnsureLoadedAsync();
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
{
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
}
_ready = true; _ready = true;
StateHasChanged(); StateHasChanged();
} }
@@ -476,6 +483,7 @@
private async Task HandleRealtimeChangedAsync() private async Task HandleRealtimeChangedAsync()
{ {
ApplyAcceptedPlaySession(); ApplyAcceptedPlaySession();
await ApplyCollaborativeSyncAsync();
if (IsAuthenticated && _knownSocialVersion != Realtime.SocialVersion) if (IsAuthenticated && _knownSocialVersion != Realtime.SocialVersion)
{ {
@@ -539,6 +547,7 @@
} }
ApplyAcceptedPlaySession(); ApplyAcceptedPlaySession();
await ApplyCollaborativeSyncAsync();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -598,11 +607,18 @@
await Store.EnsureLoadedAsync(); await Store.EnsureLoadedAsync();
var match = MatchEngine.CreateMatch(Form.ToMatchConfig()); 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); Store.SetCurrent(match);
try try
{ {
await Store.SaveAsync(); await Store.SaveAsync();
await Realtime.PublishMatchStateAsync(match, "/chrono.html");
} }
catch catch
{ {
@@ -699,7 +715,14 @@
private async Task ClearMatchAsync() private async Task ClearMatchAsync()
{ {
var sessionId = CurrentMatch?.CollaborationSessionId;
await Store.ClearAsync(); await Store.ClearAsync();
if (!string.IsNullOrWhiteSpace(sessionId))
{
await Realtime.PublishMatchStateAsync(null, "/application.html");
}
StateHasChanged(); StateHasChanged();
} }
@@ -727,8 +750,8 @@
private void ApplyAcceptedPlaySession() private void ApplyAcceptedPlaySession()
{ {
var session = Realtime.TakeAcceptedPlaySession(); var session = Realtime.ActivePlaySession;
if (session is null) if (session is null || string.Equals(_appliedActiveSessionId, session.SessionId, StringComparison.Ordinal))
{ {
return; return;
} }
@@ -736,6 +759,7 @@
Form.WhiteName = session.WhiteName; Form.WhiteName = session.WhiteName;
Form.BlackName = session.BlackName; Form.BlackName = session.BlackName;
SetupError = null; SetupError = null;
_appliedActiveSessionId = session.SessionId;
} }
private void AssignConnectedPlayerToWhite() private void AssignConnectedPlayerToWhite()
@@ -784,6 +808,32 @@
: null; : 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) private bool ResolveOnlineStatus(string subject, bool fallbackStatus)
=> Realtime.GetKnownOnlineStatus(subject) ?? fallbackStatus; => Realtime.GetKnownOnlineStatus(subject) ?? fallbackStatus;
@@ -813,12 +863,19 @@
MatchEngine.SanitizeText(right) ?? string.Empty, MatchEngine.SanitizeText(right) ?? string.Empty,
StringComparison.OrdinalIgnoreCase); 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() private void ResetSocialState()
{ {
SocialOverview = null; SocialOverview = null;
SocialLoadError = null; SocialLoadError = null;
InviteActionError = null; InviteActionError = null;
_knownSocialVersion = Realtime.SocialVersion; _knownSocialVersion = Realtime.SocialVersion;
_knownCollaborativeRevision = 0;
} }
private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user) private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user)

View File

@@ -3,6 +3,7 @@
@implements IAsyncDisposable @implements IAsyncDisposable
@inject MatchStore Store @inject MatchStore Store
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject SocialRealtimeService Realtime
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle> <PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" /> <PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
@@ -156,6 +157,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
private CancellationTokenSource? _tickerCancellation; private CancellationTokenSource? _tickerCancellation;
private bool _ready; private bool _ready;
private bool ShowArbiterModal; private bool ShowArbiterModal;
private long _knownCollaborativeRevision;
private MatchState? Match => Store.Current; 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; return;
} }
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await Store.EnsureLoadedAsync(); await Store.EnsureLoadedAsync();
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
{
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
await ApplyCollaborativeSyncAsync();
}
_ready = true; _ready = true;
if (Match is null) 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(); _tickerCancellation.Dispose();
} }
Realtime.Changed -= HandleRealtimeChanged;
await Store.FlushIfDueAsync(0); await Store.FlushIfDueAsync(0);
} }
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
await ApplyCollaborativeSyncAsync();
StateHasChanged();
}
private async Task RunTickerAsync(CancellationToken cancellationToken) private async Task RunTickerAsync(CancellationToken cancellationToken)
{ {
try try
@@ -385,7 +406,14 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
private async Task ResetMatchAsync() private async Task ResetMatchAsync()
{ {
var sessionId = Match?.CollaborationSessionId;
await Store.ClearAsync(); await Store.ClearAsync();
if (!string.IsNullOrWhiteSpace(sessionId))
{
await Realtime.PublishMatchStateAsync(null, "/application.html");
}
Navigation.NavigateTo("/application.html", replace: true); 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(); Store.MarkDirty();
await Store.SaveAsync(); 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) if (Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube)
{ {
Navigation.NavigateTo("/cube.html", replace: true); 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) private static string BoolString(bool value)
=> value ? "true" : "false"; => 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( private sealed record ChronoSummaryView(
string Title, string Title,
string Subtitle, string Subtitle,

View File

@@ -4,6 +4,7 @@
@inject BrowserBridge Browser @inject BrowserBridge Browser
@inject MatchStore Store @inject MatchStore Store
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject SocialRealtimeService Realtime
<PageTitle>ChessCubing Arena | Phase Cube</PageTitle> <PageTitle>ChessCubing Arena | Phase Cube</PageTitle>
<PageBody Page="cube" BodyClass="phase-body" /> <PageBody Page="cube" BodyClass="phase-body" />
@@ -207,6 +208,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
private bool ShowHelpModal; private bool ShowHelpModal;
private bool ShowResultModal; private bool ShowResultModal;
private string? _resultModalKey; private string? _resultModalKey;
private long _knownCollaborativeRevision;
private MatchState? Match => Store.Current; 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; return;
} }
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await Store.EnsureLoadedAsync(); await Store.EnsureLoadedAsync();
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
{
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
await ApplyCollaborativeSyncAsync();
}
_ready = true; _ready = true;
if (Match is null) 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(); _tickerCancellation.Dispose();
} }
Realtime.Changed -= HandleRealtimeChanged;
await Store.FlushIfDueAsync(0); await Store.FlushIfDueAsync(0);
} }
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
await ApplyCollaborativeSyncAsync();
UpdateResultModalState();
StateHasChanged();
}
private async Task RunTickerAsync(CancellationToken cancellationToken) private async Task RunTickerAsync(CancellationToken cancellationToken)
{ {
try try
@@ -418,6 +440,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
MatchEngine.ApplyCubeOutcome(match); MatchEngine.ApplyCubeOutcome(match);
Store.MarkDirty(); Store.MarkDirty();
await Store.SaveAsync(); await Store.SaveAsync();
await Realtime.PublishMatchStateAsync(match, "/chrono.html");
Navigation.NavigateTo("/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() private async Task ResetMatchAsync()
{ {
var sessionId = Match?.CollaborationSessionId;
await Store.ClearAsync(); await Store.ClearAsync();
if (!string.IsNullOrWhiteSpace(sessionId))
{
await Realtime.PublishMatchStateAsync(null, "/application.html");
}
Navigation.NavigateTo("/application.html", replace: true); 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(); Store.MarkDirty();
await Store.SaveAsync(); await Store.SaveAsync();
await Realtime.PublishMatchStateAsync(Match, "/cube.html");
UpdateResultModalState(); UpdateResultModalState();
StateHasChanged(); 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) private static string BoolString(bool value)
=> value ? "true" : "false"; => 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 private sealed class CubeHoldState
{ {
public bool Armed { get; set; } public bool Armed { get; set; }

View File

@@ -59,6 +59,13 @@ public sealed class MatchStore(BrowserBridge browser, UserSession userSession)
MarkDirty(); MarkDirty();
} }
public void ReplaceCurrent(MatchState? match)
{
Current = match;
IsLoaded = true;
_dirty = false;
}
public void MarkDirty() public void MarkDirty()
=> _dirty = true; => _dirty = true;

View File

@@ -1,4 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using ChessCubing.App.Models;
using ChessCubing.App.Models.Social; using ChessCubing.App.Models.Social;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
@@ -10,17 +13,24 @@ public sealed class SocialRealtimeService(
NavigationManager navigation, NavigationManager navigation,
AuthenticationStateProvider authenticationStateProvider) : IAsyncDisposable AuthenticationStateProvider authenticationStateProvider) : IAsyncDisposable
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly NavigationManager _navigation = navigation; private readonly NavigationManager _navigation = navigation;
private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider; private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider;
private readonly SemaphoreSlim _gate = new(1, 1); private readonly SemaphoreSlim _gate = new(1, 1);
private HubConnection? _hubConnection; private HubConnection? _hubConnection;
private string? _currentSubject; private string? _currentSubject;
private string? _joinedPlaySessionId;
private HashSet<string> _knownPresenceSubjects = new(StringComparer.Ordinal); private HashSet<string> _knownPresenceSubjects = new(StringComparer.Ordinal);
private HashSet<string> _onlineSubjects = new(StringComparer.Ordinal); private HashSet<string> _onlineSubjects = new(StringComparer.Ordinal);
private PlayInviteMessage? _incomingPlayInvite; private PlayInviteMessage? _incomingPlayInvite;
private PlayInviteMessage? _outgoingPlayInvite; private PlayInviteMessage? _outgoingPlayInvite;
private PlaySessionResponse? _activePlaySession; private PlaySessionResponse? _activePlaySession;
private CollaborativeMatchSnapshot? _collaborativeSnapshot;
private string? _lastInviteNotice; private string? _lastInviteNotice;
private int _socialVersion; private int _socialVersion;
private bool _started; private bool _started;
@@ -31,6 +41,10 @@ public sealed class SocialRealtimeService(
public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite; public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite;
public PlaySessionResponse? ActivePlaySession => _activePlaySession;
public CollaborativeMatchSnapshot? CollaborativeSnapshot => _collaborativeSnapshot;
public string? LastInviteNotice => _lastInviteNotice; public string? LastInviteNotice => _lastInviteNotice;
public int SocialVersion => _socialVersion; public int SocialVersion => _socialVersion;
@@ -63,6 +77,43 @@ public sealed class SocialRealtimeService(
: null; : 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) public async Task SendPlayInviteAsync(string recipientSubject, string recipientColor)
{ {
_lastInviteNotice = null; _lastInviteNotice = null;
@@ -105,13 +156,6 @@ public sealed class SocialRealtimeService(
await _hubConnection.InvokeAsync("CancelPlayInvite", inviteId); await _hubConnection.InvokeAsync("CancelPlayInvite", inviteId);
} }
public PlaySessionResponse? TakeAcceptedPlaySession()
{
var session = _activePlaySession;
_activePlaySession = null;
return session;
}
public void ClearInviteNotice() public void ClearInviteNotice()
{ {
if (string.IsNullOrWhiteSpace(_lastInviteNotice)) if (string.IsNullOrWhiteSpace(_lastInviteNotice))
@@ -154,10 +198,11 @@ public sealed class SocialRealtimeService(
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User; var user = authState.User;
var subject = ResolveSubject(user); var subject = ResolveSubject(user);
var preserveSessionState = string.Equals(subject, _currentSubject, StringComparison.Ordinal);
if (string.IsNullOrWhiteSpace(subject)) if (string.IsNullOrWhiteSpace(subject))
{ {
await DisconnectUnsafeAsync(); await DisconnectUnsafeAsync(clearSessionState: true);
return; return;
} }
@@ -168,12 +213,17 @@ public sealed class SocialRealtimeService(
return; return;
} }
await DisconnectUnsafeAsync(); await DisconnectUnsafeAsync(clearSessionState: !preserveSessionState);
_currentSubject = subject; _currentSubject = subject;
_hubConnection = BuildHubConnection(); _hubConnection = BuildHubConnection();
RegisterHandlers(_hubConnection); RegisterHandlers(_hubConnection);
await _hubConnection.StartAsync(); await _hubConnection.StartAsync();
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
{
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
}
} }
finally finally
{ {
@@ -241,18 +291,31 @@ public sealed class SocialRealtimeService(
RaiseChanged(); RaiseChanged();
}); });
connection.On<PlaySessionResponse>("PlayInviteAccepted", session => connection.On<CollaborativeMatchStateMessage>("CollaborativeMatchStateUpdated", message =>
{
ApplyCollaborativeState(message);
RaiseChanged();
});
connection.On<PlaySessionResponse>("PlayInviteAccepted", async session =>
{ {
_incomingPlayInvite = null; _incomingPlayInvite = null;
_outgoingPlayInvite = null; _outgoingPlayInvite = null;
_activePlaySession = session; _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(); RaiseChanged();
}); });
connection.Reconnected += async _ => connection.Reconnected += async _ =>
{ {
await RequestPresenceSnapshotAsync(); await RequestPresenceSnapshotAsync();
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
{
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
}
RaiseChanged(); 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<CollaborativeMatchStateMessage?>("JoinPlaySession", sessionId);
_joinedPlaySessionId = sessionId;
if (latestState is not null)
{
ApplyCollaborativeState(latestState);
}
else if (_collaborativeSnapshot?.SessionId != sessionId)
{
_collaborativeSnapshot = null;
}
}
private void ApplyInvite(PlayInviteMessage invite) private void ApplyInvite(PlayInviteMessage invite)
{ {
if (string.IsNullOrWhiteSpace(_currentSubject)) 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<MatchState>(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(); _knownPresenceSubjects.Clear();
_onlineSubjects.Clear(); _onlineSubjects.Clear();
_incomingPlayInvite = null; _incomingPlayInvite = null;
_outgoingPlayInvite = null; _outgoingPlayInvite = null;
_activePlaySession = null;
_lastInviteNotice = null; if (clearSessionState)
{
_activePlaySession = null;
_joinedPlaySessionId = null;
_collaborativeSnapshot = null;
_lastInviteNotice = null;
}
if (_hubConnection is not null) if (_hubConnection is not null)
{ {

View File

@@ -75,6 +75,7 @@ builder.Services.AddSingleton<MySqlUserProfileStore>();
builder.Services.AddSingleton<MySqlSocialStore>(); builder.Services.AddSingleton<MySqlSocialStore>();
builder.Services.AddSingleton<ConnectedUserTracker>(); builder.Services.AddSingleton<ConnectedUserTracker>();
builder.Services.AddSingleton<PlayInviteCoordinator>(); builder.Services.AddSingleton<PlayInviteCoordinator>();
builder.Services.AddSingleton<CollaborativeMatchCoordinator>();
var app = builder.Build(); var app = builder.Build();

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 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); public sealed class SocialValidationException(string message) : Exception(message);

View File

@@ -9,12 +9,14 @@ public sealed class SocialHub(
ConnectedUserTracker tracker, ConnectedUserTracker tracker,
MySqlSocialStore socialStore, MySqlSocialStore socialStore,
MySqlUserProfileStore profileStore, MySqlUserProfileStore profileStore,
PlayInviteCoordinator playInviteCoordinator) : Hub PlayInviteCoordinator playInviteCoordinator,
CollaborativeMatchCoordinator collaborativeMatchCoordinator) : Hub
{ {
private readonly ConnectedUserTracker _tracker = tracker; private readonly ConnectedUserTracker _tracker = tracker;
private readonly MySqlSocialStore _socialStore = socialStore; private readonly MySqlSocialStore _socialStore = socialStore;
private readonly MySqlUserProfileStore _profileStore = profileStore; private readonly MySqlUserProfileStore _profileStore = profileStore;
private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator; private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator;
private readonly CollaborativeMatchCoordinator _collaborativeMatchCoordinator = collaborativeMatchCoordinator;
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
@@ -99,6 +101,7 @@ public sealed class SocialHub(
if (accept) if (accept)
{ {
var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject); var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject);
_collaborativeMatchCoordinator.RegisterSession(accepted.Session);
await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject]) await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject])
.SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted); .SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted);
return; 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() private AuthenticatedSiteUser RequireCurrentUser()
=> AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal()) => AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal())
?? throw new HubException("La session utilisateur est incomplete."); ?? throw new HubException("La session utilisateur est incomplete.");
@@ -165,4 +212,7 @@ public sealed class SocialHub(
IsOnline = isOnline, IsOnline = isOnline,
}); });
} }
private static string BuildSessionGroupName(string sessionId)
=> $"play-session:{sessionId}";
} }