Synchronise les parties entre les deux devices
This commit is contained in:
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
89
ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs
Normal file
89
ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user