namespace ChessCubing.Server.Social; public sealed class PlayInviteCoordinator { private static readonly TimeSpan InviteLifetime = TimeSpan.FromMinutes(2); private readonly object _sync = new(); private readonly Dictionary _invitesById = new(StringComparer.Ordinal); private readonly Dictionary _inviteByParticipant = new(StringComparer.Ordinal); public PlayInviteMessage CreateInvite(PlayInviteParticipant sender, PlayInviteParticipant recipient, string recipientColor) { var normalizedColor = NormalizeRecipientColor(recipientColor); lock (_sync) { CleanupExpiredUnsafe(); if (_inviteByParticipant.ContainsKey(sender.Subject)) { throw new SocialValidationException("Une invitation de partie est deja en cours pour ton compte."); } if (_inviteByParticipant.ContainsKey(recipient.Subject)) { throw new SocialValidationException("Cet ami traite deja une autre invitation de partie."); } var nowUtc = DateTime.UtcNow; var state = new PlayInviteState( Guid.NewGuid().ToString("N"), sender, recipient, normalizedColor, nowUtc, nowUtc.Add(InviteLifetime)); _invitesById[state.InviteId] = state; _inviteByParticipant[sender.Subject] = state.InviteId; _inviteByParticipant[recipient.Subject] = state.InviteId; return MapInvite(state); } } public PlayInviteCloseResult CancelInvite(string inviteId, string senderSubject) { lock (_sync) { var state = GetActiveInviteUnsafe(inviteId); if (!string.Equals(state.Sender.Subject, senderSubject, StringComparison.Ordinal)) { throw new SocialValidationException("Seul l'expediteur peut annuler cette invitation."); } RemoveInviteUnsafe(state); return new PlayInviteCloseResult( state.Sender.Subject, state.Recipient.Subject, new PlayInviteClosedMessage { InviteId = state.InviteId, Reason = "cancelled", Message = $"{state.Sender.DisplayName} a annule l'invitation de partie.", }); } } public PlayInviteCloseResult DeclineInvite(string inviteId, string recipientSubject) { lock (_sync) { var state = GetActiveInviteUnsafe(inviteId); if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal)) { throw new SocialValidationException("Seul le destinataire peut refuser cette invitation."); } RemoveInviteUnsafe(state); return new PlayInviteCloseResult( state.Sender.Subject, state.Recipient.Subject, new PlayInviteClosedMessage { InviteId = state.InviteId, Reason = "declined", Message = $"{state.Recipient.DisplayName} a refuse la partie.", }); } } public PlayInviteAcceptResult AcceptInvite(string inviteId, string recipientSubject) { lock (_sync) { var state = GetActiveInviteUnsafe(inviteId); if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal)) { throw new SocialValidationException("Seul le destinataire peut accepter cette invitation."); } RemoveInviteUnsafe(state); var white = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal) ? state.Recipient : state.Sender; var black = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal) ? state.Sender : state.Recipient; var session = new PlaySessionResponse { SessionId = Guid.NewGuid().ToString("N"), WhiteSubject = white.Subject, WhiteName = white.DisplayName, BlackSubject = black.Subject, BlackName = black.DisplayName, InitiatorSubject = state.Sender.Subject, RecipientSubject = state.Recipient.Subject, ConfirmedUtc = DateTime.UtcNow, }; return new PlayInviteAcceptResult(state.Sender.Subject, state.Recipient.Subject, session); } } private static string NormalizeRecipientColor(string recipientColor) { var normalized = recipientColor.Trim().ToLowerInvariant(); return normalized switch { "white" => "white", "black" => "black", _ => throw new SocialValidationException("La couleur demandee pour l'ami doit etre blanc ou noir."), }; } private static PlayInviteMessage MapInvite(PlayInviteState state) => new() { InviteId = state.InviteId, SenderSubject = state.Sender.Subject, SenderUsername = state.Sender.Username, SenderDisplayName = state.Sender.DisplayName, RecipientSubject = state.Recipient.Subject, RecipientUsername = state.Recipient.Username, RecipientDisplayName = state.Recipient.DisplayName, RecipientColor = state.RecipientColor, CreatedUtc = state.CreatedUtc, ExpiresUtc = state.ExpiresUtc, }; private PlayInviteState GetActiveInviteUnsafe(string inviteId) { CleanupExpiredUnsafe(); if (!_invitesById.TryGetValue(inviteId, out var state)) { throw new SocialValidationException("Cette invitation de partie n'est plus disponible."); } return state; } private void CleanupExpiredUnsafe() { var nowUtc = DateTime.UtcNow; var expiredInviteIds = _invitesById.Values .Where(state => state.ExpiresUtc <= nowUtc) .Select(state => state.InviteId) .ToArray(); foreach (var expiredInviteId in expiredInviteIds) { if (_invitesById.TryGetValue(expiredInviteId, out var state)) { RemoveInviteUnsafe(state); } } } private void RemoveInviteUnsafe(PlayInviteState state) { _invitesById.Remove(state.InviteId); _inviteByParticipant.Remove(state.Sender.Subject); _inviteByParticipant.Remove(state.Recipient.Subject); } private sealed record PlayInviteState( string InviteId, PlayInviteParticipant Sender, PlayInviteParticipant Recipient, string RecipientColor, DateTime CreatedUtc, DateTime ExpiresUtc); } public readonly record struct PlayInviteParticipant( string Subject, string Username, string DisplayName); public sealed record PlayInviteCloseResult( string SenderSubject, string RecipientSubject, PlayInviteClosedMessage ClosedMessage); public sealed record PlayInviteAcceptResult( string SenderSubject, string RecipientSubject, PlaySessionResponse Session);