using Mocha2023.Auth; using Mocha2023.Classes; using Mocha2023.Classes.DBs; using Mocha2023.Classes.DBs.DBClasses; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.IO; using System.Threading; using System.Threading.Tasks; using System.Text.Json; using SystemJsonSerializer = System.Text.Json.JsonSerializer; using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses; namespace Mocha2023.Controllers { public sealed class NotificationHub : Hub { public override async Task OnConnectedAsync() { HttpRequest? request = Context.GetHttpContext()?.Request; long? playerId = request == null ? null : AuthStuff.GetPlayerId(request); if (!playerId.HasValue) { Console.WriteLine( $"[SignalR] Rejected unauthenticated connection {Context.ConnectionId}"); Context.Abort(); return; } bool becameOnline = NotiController.RegisterConnection( Context.ConnectionId, playerId.Value); NotiController.RegisterHubContext(Context.ConnectionId, Context); NotiController.EnsurePersistentSubscriptions( Context.ConnectionId, playerId.Value); Console.WriteLine( $"[SignalR] Player {playerId.Value} connected as {Context.ConnectionId}"); await base.OnConnectedAsync(); await Clients.Caller.SendAsync("OnConnect"); Console.WriteLine( $"[SignalR] Player {playerId.Value} OnConnect sent"); await NotiController.DeliverPendingNotificationsAsync(playerId.Value); await NotiController.NotifyAnnouncementsUpdatedAsync(playerId.Value); await NotiController.PrimeConnectionAsync( Context.ConnectionId, playerId.Value); } public override async Task OnDisconnectedAsync(Exception? exception) { bool found = NotiController.UnregisterConnection( Context.ConnectionId, out long playerId, out bool becameOffline); NotiController.UnregisterHubContext(Context.ConnectionId); await base.OnDisconnectedAsync(exception); if (!found) return; Console.WriteLine( $"[SignalR] Player {playerId} disconnected from {Context.ConnectionId}" + (exception == null ? string.Empty : $": {exception.Message}")); } public async Task SubscribeToPlayers( PlayerSubscriptionListDTO? subscriptionList) { List requestedPlayerIds = subscriptionList?.PlayerIds ?? new List(); List newlySubscribedPlayerIds = NotiController.ReplacePlayerSubscriptions( Context.ConnectionId, requestedPlayerIds); HttpRequest? request = Context.GetHttpContext()?.Request; long? ownerPlayerId = request == null ? null : AuthStuff.GetPlayerId(request); if (ownerPlayerId.HasValue && newlySubscribedPlayerIds.Count > 0) { await NotiController.PrimePlayerSubscriptionsAsync( Context.ConnectionId, ownerPlayerId.Value, newlySubscribedPlayerIds.Select(id => (int)id)); } return "200 OK"; } public Task SubscribeToPlayer( PlayerSubscriptionListDTO? subscriptionList) => SubscribeToPlayers(subscriptionList); public Task UnsubscribeFromPlayers( PlayerSubscriptionListDTO? subscriptionList) { NotiController.RemovePlayerSubscriptions( Context.ConnectionId, subscriptionList?.PlayerIds ?? new List()); return Task.FromResult("200 OK"); } public Task UnsubscribeFromPlayer( PlayerSubscriptionListDTO? subscriptionList) => UnsubscribeFromPlayers(subscriptionList); public Task SubscribeToChannels( ChannelSubscriptionListDTO? subscriptionList) { NotiController.ReplaceChannelSubscriptions( Context.ConnectionId, subscriptionList?.Channels ?? new List()); return Task.FromResult("200 OK"); } public Task UnsubscribeFromChannels( ChannelSubscriptionListDTO? subscriptionList) { NotiController.RemoveChannelSubscriptions( Context.ConnectionId, subscriptionList?.Channels ?? new List()); return Task.FromResult("200 OK"); } public Task SubscribeTo( ChannelSubscriptionListDTO? subscriptionList) => SubscribeToChannels(subscriptionList); public Task UnsubscribeFrom( ChannelSubscriptionListDTO? subscriptionList) => UnsubscribeFromChannels(subscriptionList); } public sealed class PlayerSubscriptionListDTO { public List PlayerIds { get; set; } = new(); } public sealed class ChannelSubscriptionListDTO { public List Channels { get; set; } = new(); } public static class NotiController { public sealed class GiftLiveDelivery { public long ReceiverPlayerId { get; } public object? Gift { get; } public GiftLiveDelivery( long receiverPlayerId, object? gift) { ReceiverPlayerId = receiverPlayerId; Gift = gift; } } public sealed class GiftLiveDeliveryResult { public int TargetPlayers { get; set; } public int LivePlayers { get; set; } public int LiveSockets { get; set; } public int OfflinePlayers { get; set; } } private sealed class PreparedGiftLiveDelivery { public long SenderPlayerId { get; set; } public long ReceiverPlayerId { get; set; } public object Payload { get; set; } = new object(); public long GiftPackageId { get; set; } public HashSet PrimedConnectionIds { get; } = new(StringComparer.Ordinal); } private const int MaxPlayerSubscriptionsPerConnection = 250; private const string ClientNotificationHubTarget = "Notification"; private const string PresenceUpdateNotificationId = "PresenceUpdate"; private const string RoomUpdateNotificationId = "RoomUpdate"; private const string AccountUpdateNotificationId = "AccountUpdate"; private const string SelfAccountUpdateNotificationId = "SelfAccountUpdate"; private const string RelationshipChangedTarget = "1"; private const string MessageReceivedTarget = "2"; private const string SubscriptionUpdatePresenceTarget = "12"; private const string ServerMaintenanceTarget = "25"; private const string GiftPackageReceivedTarget = "30"; private const string GiftPackageReceivedImmediateTarget = "31"; private const string ConsumableMappingRemovedTarget = "71"; private const string GiftConsumedTarget = "GiftConsumed"; private const string RelationshipsInvalidTarget = "50"; private const string ChatMessageReceivedTarget = "ChatMessageReceived"; private const string CommunityBoardUpdateTarget = "CommunityBoardUpdate"; private const string CommunityBoardAnnouncementUpdateTarget = "96"; private const string FreeGiftButtonItemsAddedTarget = "110"; private static readonly IReadOnlyDictionary PushTargetNames = new Dictionary(StringComparer.Ordinal) { [RelationshipChangedTarget] = "RelationshipChanged", [MessageReceivedTarget] = "MessageReceived", [SubscriptionUpdatePresenceTarget] = "SubscriptionUpdatePresence", [ServerMaintenanceTarget] = "ServerMaintenance", [GiftPackageReceivedTarget] = "GiftPackageReceived", [GiftPackageReceivedImmediateTarget] = "GiftPackageReceivedImmediate", [ConsumableMappingRemovedTarget] = "ConsumableMappingRemoved", [GiftConsumedTarget] = "GiftConsumed", [RelationshipsInvalidTarget] = "RelationshipsInvalid", [ChatMessageReceivedTarget] = "ChatMessageReceived", [CommunityBoardUpdateTarget] = "CommunityBoardUpdate", [CommunityBoardAnnouncementUpdateTarget] = "CommunityBoardAnnouncementUpdate", [FreeGiftButtonItemsAddedTarget] = "FreeGiftButtonItemsAdded" }; private static IHubContext? _hubContext; private static FileSystemWatcher? _announcementWatcher; private static int _announcementRefreshPending; private static readonly ConcurrentDictionary ConnectionPlayers = new(StringComparer.Ordinal); private static readonly ConcurrentDictionary> PlayerConnections = new(); private static readonly ConcurrentDictionary ActiveHubContexts = new(StringComparer.Ordinal); internal static void RegisterHubContext(string connectionId, HubCallerContext context) => ActiveHubContexts[connectionId] = context; internal static void UnregisterHubContext(string connectionId) => ActiveHubContexts.TryRemove(connectionId, out _); internal static int ForceDisconnectPlayer(long playerId) { if (!PlayerConnections.TryGetValue(playerId, out var sockets)) return 0; int aborted = 0; foreach (string connectionId in sockets.Keys.ToList()) { if (ActiveHubContexts.TryGetValue(connectionId, out HubCallerContext? context)) { try { context.Abort(); aborted++; } catch (Exception exception) { Console.WriteLine( $"[SignalR] Failed aborting {connectionId} for player {playerId}: {exception.Message}"); } } } return aborted; } private static readonly ConcurrentDictionary> ConnectionPlayerSubscriptions = new(StringComparer.Ordinal); private static readonly ConcurrentDictionary> PlayerSubscribers = new(); private static readonly ConcurrentDictionary> ConnectionChannelSubscriptions = new(StringComparer.Ordinal); public static void Initialize(IHubContext hubContext) { _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); InitializeAnnouncementWatcher(); Console.WriteLine("[SignalR] Notification hub broadcaster initialized"); } internal static int ConnectedSocketCount => ConnectionPlayers.Count; internal static int GetPlayerSocketCount(long playerId) => PlayerConnections.TryGetValue(playerId, out var connections) ? connections.Count : 0; private static void InitializeAnnouncementWatcher() { try { Directory.CreateDirectory(Program.dataDir); _announcementWatcher?.Dispose(); _announcementWatcher = new FileSystemWatcher( Program.dataDir, "announcements.json") { IncludeSubdirectories = false, NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size, EnableRaisingEvents = true }; _announcementWatcher.Changed += (_, _) => QueueAnnouncementBroadcast(); _announcementWatcher.Created += (_, _) => QueueAnnouncementBroadcast(); _announcementWatcher.Deleted += (_, _) => QueueAnnouncementBroadcast(); _announcementWatcher.Renamed += (_, _) => QueueAnnouncementBroadcast(); Console.WriteLine( $"[Announcements] Watching {Path.Combine(Program.dataDir, "announcements.json")}"); } catch (Exception exception) { Console.WriteLine( $"[Announcements] File watcher disabled: {exception.Message}"); } } private static void QueueAnnouncementBroadcast() { if (Interlocked.Exchange(ref _announcementRefreshPending, 1) != 0) return; _ = Task.Run(async () => { try { await Task.Delay(300); await BroadcastAnnouncementsUpdatedAsync(); } catch (Exception exception) { Console.WriteLine( $"[Announcements] Live refresh failed: {exception.Message}"); } finally { Interlocked.Exchange(ref _announcementRefreshPending, 0); } }); } internal static bool RegisterConnection(string connectionId, long playerId) { if (ConnectionPlayers.TryGetValue(connectionId, out long oldPlayerId)) { UnregisterConnection( connectionId, out _, out _); Console.WriteLine( $"[SignalR] Replaced reused connection {connectionId} " + $"from player {oldPlayerId} with player {playerId}"); } ConnectionPlayers[connectionId] = playerId; ConcurrentDictionary sockets = PlayerConnections .GetOrAdd(playerId, _ => new ConcurrentDictionary( StringComparer.Ordinal)); bool wasOffline = sockets.IsEmpty; sockets[connectionId] = 0; ConnectionPlayerSubscriptions.TryAdd( connectionId, new ConcurrentDictionary()); ConnectionChannelSubscriptions.TryAdd( connectionId, new ConcurrentDictionary(StringComparer.Ordinal)); return wasOffline; } internal static bool UnregisterConnection( string connectionId, out long playerId, out bool becameOffline) { becameOffline = false; if (!ConnectionPlayers.TryRemove(connectionId, out playerId)) return false; if (PlayerConnections.TryGetValue(playerId, out var sockets)) { sockets.TryRemove(connectionId, out _); if (sockets.IsEmpty) { PlayerConnections.TryRemove(playerId, out _); becameOffline = true; } } if (ConnectionPlayerSubscriptions.TryRemove( connectionId, out var subscribedPlayers)) { foreach (long subscribedPlayerId in subscribedPlayers.Keys) RemoveSubscriberIndex(connectionId, subscribedPlayerId); } ConnectionChannelSubscriptions.TryRemove(connectionId, out _); return true; } // Returns only the subject IDs that were newly added by this call - // the client re-sends its full subscription list on every call // (observed multiple times per second per connection), so priming // the whole requested list unconditionally on every call turned // every one of those into a full profile+presence re-broadcast for // everyone already subscribed, flooding the hub with redundant // traffic instead of just the players that are actually new. internal static List ReplacePlayerSubscriptions( string connectionId, IEnumerable requestedPlayerIds) { if (!ConnectionPlayers.TryGetValue( connectionId, out long ownerPlayerId)) { return new List(); } ConcurrentDictionary current = ConnectionPlayerSubscriptions.GetOrAdd( connectionId, _ => new ConcurrentDictionary()); long[] requested = requestedPlayerIds .Where(id => id > 0) .Select(id => (long)id) .Concat(RelationshipDB.GetPersistentLiveSubjectIds(ownerPlayerId)) .Where(id => id != ownerPlayerId) .Distinct() .Take(MaxPlayerSubscriptionsPerConnection) .ToArray(); var newlyAdded = new List(); foreach (long addedPlayerId in requested) { if (!current.TryAdd(addedPlayerId, 0)) continue; newlyAdded.Add(addedPlayerId); PlayerSubscribers .GetOrAdd( addedPlayerId, _ => new ConcurrentDictionary( StringComparer.Ordinal)) [connectionId] = 0; } if (newlyAdded.Count > 0) { Console.WriteLine( $"[SignalR] Player {ownerPlayerId} subscriptions=" + string.Join(',', current.Keys.OrderBy(id => id))); } return newlyAdded; } internal static void RemovePlayerSubscriptions( string connectionId, IEnumerable playerIds) { if (!ConnectionPlayerSubscriptions.TryGetValue( connectionId, out var current)) { return; } foreach (long playerId in playerIds .Where(id => id > 0) .Select(id => (long)id) .Distinct()) { current.TryRemove(playerId, out _); RemoveSubscriberIndex(connectionId, playerId); } } internal static void ReplaceChannelSubscriptions( string connectionId, IEnumerable channels) { ConcurrentDictionary current = ConnectionChannelSubscriptions.GetOrAdd( connectionId, _ => new ConcurrentDictionary( StringComparer.Ordinal)); HashSet desired = channels .Where(channel => !string.IsNullOrWhiteSpace(channel)) .Select(channel => channel.Trim()) .Distinct(StringComparer.Ordinal) .Take(250) .ToHashSet(StringComparer.Ordinal); foreach (string removed in current.Keys.Except( desired, StringComparer.Ordinal)) { current.TryRemove(removed, out _); } foreach (string added in desired.Except( current.Keys, StringComparer.Ordinal)) { current[added] = 0; } } internal static void RemoveChannelSubscriptions( string connectionId, IEnumerable channels) { if (!ConnectionChannelSubscriptions.TryGetValue( connectionId, out var current)) { return; } foreach (string channel in channels .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim()) .Distinct(StringComparer.Ordinal)) { current.TryRemove(channel, out _); } } private static void RemoveSubscriberIndex( string connectionId, long subscribedPlayerId) { if (!PlayerSubscribers.TryGetValue( subscribedPlayerId, out var subscribers)) { return; } subscribers.TryRemove(connectionId, out _); if (subscribers.IsEmpty) PlayerSubscribers.TryRemove(subscribedPlayerId, out _); } internal static void EnsurePersistentSubscriptions( string connectionId, long ownerPlayerId) { if (ownerPlayerId <= 0 || !ConnectionPlayers.ContainsKey(connectionId)) { return; } ConcurrentDictionary current = ConnectionPlayerSubscriptions.GetOrAdd( connectionId, _ => new ConcurrentDictionary()); foreach (long subjectPlayerId in RelationshipDB.GetPersistentLiveSubjectIds(ownerPlayerId) .Take(MaxPlayerSubscriptionsPerConnection)) { if (subjectPlayerId <= 0 || subjectPlayerId == ownerPlayerId || !current.TryAdd(subjectPlayerId, 0)) { continue; } PlayerSubscribers .GetOrAdd( subjectPlayerId, _ => new ConcurrentDictionary( StringComparer.Ordinal)) [connectionId] = 0; } } private static void RefreshPersistentSubscriptionsForPlayer( long playerId) { if (!PlayerConnections.TryGetValue(playerId, out var connections)) return; foreach (string connectionId in connections.Keys) EnsurePersistentSubscriptions(connectionId, playerId); } internal static async Task PrimeConnectionAsync( string connectionId, long ownerPlayerId) { if (!ConnectionPlayers.ContainsKey(connectionId)) return; EnsurePersistentSubscriptions(connectionId, ownerPlayerId); long[] subjectIds = ConnectionPlayerSubscriptions.TryGetValue( connectionId, out var subscriptions) ? subscriptions.Keys .Where(id => id > 0) .Distinct() .Take(MaxPlayerSubscriptionsPerConnection) .ToArray() : Array.Empty(); long[] profileIds = subjectIds .Append(ownerPlayerId) .Distinct() .ToArray(); foreach (PlayerDTOBase account in PlayerDB.GetAccountsBulk( profileIds.ToList(), callerId: ownerPlayerId)) { string eventId = account.accountId == ownerPlayerId ? SelfAccountUpdateNotificationId : AccountUpdateNotificationId; await SendProfilePushToConnectionIdsAsync( new[] { connectionId }, account, eventId); } foreach (ClientRelationshipDTO relationship in RelationshipDB.GetClientRelationships(ownerPlayerId)) { await SendPushEventToConnectionIdsAsync( new[] { connectionId }, RelationshipChangedTarget, relationship); } foreach (long subjectPlayerId in subjectIds) { Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat( subjectPlayerId, ownerPlayerId); await SendPresencePushToConnectionIdsAsync( new[] { connectionId }, heartbeat); } Console.WriteLine( $"[LIVE PRIME] player={ownerPlayerId} " + $"profiles={profileIds.Length} " + $"relationships={RelationshipDB.GetClientRelationships(ownerPlayerId).Count} " + $"presence={subjectIds.Length}"); } internal static async Task PrimePlayerSubscriptionsAsync( string connectionId, long ownerPlayerId, IEnumerable requestedPlayerIds) { if (!ConnectionPlayers.ContainsKey(connectionId)) return; long[] subjectIds = requestedPlayerIds .Where(id => id > 0) .Select(id => (long)id) .Where(id => id != ownerPlayerId) .Distinct() .Take(MaxPlayerSubscriptionsPerConnection) .ToArray(); if (subjectIds.Length == 0) return; foreach (PlayerDTOBase account in PlayerDB.GetAccountsBulk( subjectIds.ToList(), callerId: ownerPlayerId)) { await SendProfilePushToConnectionIdsAsync( new[] { connectionId }, account); } foreach (long subjectPlayerId in subjectIds) { Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat( subjectPlayerId, ownerPlayerId); await SendPresencePushToConnectionIdsAsync( new[] { connectionId }, heartbeat); } Console.WriteLine( $"[LIVE SUBSCRIBE PRIME] player={ownerPlayerId} " + $"subjects={subjectIds.Length} socket={connectionId}"); } private static void AddPlayerConnections( ISet destination, long playerId) { if (playerId <= 0 || !PlayerConnections.TryGetValue(playerId, out var connections)) { return; } foreach (string connectionId in connections.Keys) destination.Add(connectionId); } private static string[] GetProfileRecipientConnectionIds( long subjectPlayerId) { var recipients = new HashSet(StringComparer.Ordinal); AddPlayerConnections(recipients, subjectPlayerId); if (PlayerSubscribers.TryGetValue( subjectPlayerId, out var explicitSubscribers)) { recipients.UnionWith(explicitSubscribers.Keys); } foreach (long watcherPlayerId in RelationshipDB.GetPersistentLiveWatcherIds(subjectPlayerId)) { AddPlayerConnections(recipients, watcherPlayerId); } foreach (long roomPeerPlayerId in PlayerDB.GetActiveSameInstancePlayerIds(subjectPlayerId)) { AddPlayerConnections(recipients, roomPeerPlayerId); } return recipients.ToArray(); } private static string[] GetPresenceRecipientConnectionIds( long subjectPlayerId) { var recipients = new HashSet(StringComparer.Ordinal); if (PlayerSubscribers.TryGetValue( subjectPlayerId, out var explicitSubscribers)) { recipients.UnionWith(explicitSubscribers.Keys); } foreach (long watcherPlayerId in RelationshipDB.GetPersistentLiveWatcherIds(subjectPlayerId)) { AddPlayerConnections(recipients, watcherPlayerId); } if (PlayerConnections.TryGetValue(subjectPlayerId, out var own)) recipients.ExceptWith(own.Keys); return recipients.ToArray(); } public static bool IsPlayerConnected(long playerId) => PlayerConnections.TryGetValue(playerId, out var connections) && !connections.IsEmpty; public static object GetDebugStatus(long playerId) { string[] connectionIds = PlayerConnections.TryGetValue( playerId, out var own) ? own.Keys.ToArray() : Array.Empty(); return new { playerId, connected = connectionIds.Length > 0, connectionCount = connectionIds.Length, connections = connectionIds.Select(connectionId => new { connectionId, playerIds = ConnectionPlayerSubscriptions.TryGetValue( connectionId, out var subscriptions) ? subscriptions.Keys.OrderBy(id => id).ToArray() : Array.Empty() }).ToArray() }; } public static Task SendNotificationToPlayer(long playerId, object message) { JsonElement root = SystemJsonSerializer.SerializeToElement(message); if (root.ValueKind != JsonValueKind.Object || !TryGetPropertyIgnoreCase(root, "target", out JsonElement targetElement)) { return Task.CompletedTask; } string? target = targetElement.GetString(); if (string.IsNullOrWhiteSpace(target)) return Task.CompletedTask; object?[] arguments = Array.Empty(); if (TryGetPropertyIgnoreCase(root, "arguments", out JsonElement argumentsElement) && argumentsElement.ValueKind == JsonValueKind.Array) { arguments = argumentsElement .EnumerateArray() .Select(argument => (object?)argument.Clone()) .ToArray(); } return SendHubEventToPlayerAsync(playerId, target, arguments); } public static Task SendHubEventToPlayerAsync( long playerId, string target, params object?[] arguments) { if (!PlayerConnections.TryGetValue(playerId, out var connections)) return Task.CompletedTask; return SendTargetToConnectionsAsync( connections.Keys, target, arguments); } public static Task SendHubEventToSubscribersAsync( long subjectPlayerId, string target, params object?[] arguments) { if (!PlayerSubscribers.TryGetValue( subjectPlayerId, out var subscribers)) { return Task.CompletedTask; } return SendTargetToConnectionsAsync( subscribers.Keys, target, arguments); } public static async Task DeliverPendingNotificationsAsync(long playerId) { List pending = NotificationDB.GetMessages(playerId) .OrderBy(message => message.CreatedAt) .Take(100) .ToList(); if (pending.Count == 0) return; Console.WriteLine( $"[NOTIFICATION REPLAY] player={playerId} count={pending.Count}"); foreach (NotificationDB.ClientNotification message in pending) { await SendPushEventToPlayerAsync( playerId, MessageReceivedTarget, message); } } public static async Task NotifyAnnouncementsUpdatedAsync(long playerId) { await Task.WhenAll( SendPushSignalToPlayerAsync( playerId, CommunityBoardAnnouncementUpdateTarget), SendPushSignalToPlayerAsync( playerId, CommunityBoardUpdateTarget)); } public static async Task BroadcastAnnouncementsUpdatedAsync() { long[] connectedPlayers = PlayerConnections.Keys .Where(playerId => playerId > 0) .Distinct() .ToArray(); if (connectedPlayers.Length == 0) return; Console.WriteLine( $"[Announcements] Broadcasting live refresh to " + $"{connectedPlayers.Length} connected players"); await Task.WhenAll(connectedPlayers.Select( NotifyAnnouncementsUpdatedAsync)); } public static async Task BroadcastServerMaintenanceAsync(int startsInMinutes) { if (startsInMinutes < 0) throw new ArgumentOutOfRangeException(nameof(startsInMinutes)); long[] connectedPlayers = PlayerConnections.Keys .Where(playerId => playerId > 0) .Distinct() .ToArray(); await Task.WhenAll(connectedPlayers.Select(playerId => SendPushSignalToPlayerAsync( playerId, ServerMaintenanceTarget))); Console.WriteLine( $"[Maintenance] Sent {startsInMinutes}-minute countdown to " + $"{connectedPlayers.Length} connected player(s)"); } public static async Task NotifyFriendRequestAsync( long senderPlayerId, long receiverPlayerId) { NotificationDB.ClientNotification message = NotificationDB.CreateFriendInvite( senderPlayerId, receiverPlayerId); await SendPushEventToPlayerAsync( receiverPlayerId, MessageReceivedTarget, message); await NotifyRelationshipChangedAsync( senderPlayerId, receiverPlayerId); } public static async Task NotifyFriendAcceptedAsync( long acceptingPlayerId, long requesterPlayerId) { NotificationDB.ClientNotification message = NotificationDB.CreateFriendAccepted( acceptingPlayerId, requesterPlayerId); await SendPushEventToPlayerAsync( requesterPlayerId, MessageReceivedTarget, message); await NotifyRelationshipChangedAsync( acceptingPlayerId, requesterPlayerId); } public static async Task NotifyFriendIntroductionAsync( long senderPlayerId, long receiverPlayerId, long aboutPlayerId) { NotificationDB.ClientNotification message = NotificationDB.CreateFriendIntroduction( senderPlayerId, receiverPlayerId, aboutPlayerId); await SendPushEventToPlayerAsync( receiverPlayerId, MessageReceivedTarget, message); } public static Task NotifyFriendRemovedAsync( long firstPlayerId, long secondPlayerId) => NotifyRelationshipChangedAsync(firstPlayerId, secondPlayerId); public static async Task NotifyChatMessageReceivedAsync( long receiverPlayerId, object chatMessageDto) { int socketCount = PlayerConnections.TryGetValue( receiverPlayerId, out var sockets) ? sockets.Count : 0; Console.WriteLine( $"[CHAT PUSH] receiver={receiverPlayerId} " + $"eventId={ChatMessageReceivedTarget} sockets={socketCount}"); await SendPushEventToPlayerAsync( receiverPlayerId, ChatMessageReceivedTarget, chatMessageDto); } public static Task NotifyRelationshipChangedAsync( long firstPlayerId, long secondPlayerId) => NotifyRelationshipFlagsChangedAsync( firstPlayerId, secondPlayerId, "relationship-change"); public static async Task NotifyRelationshipFlagsChangedAsync( long firstPlayerId, long secondPlayerId, string reason) { RefreshPersistentSubscriptionsForPlayer(firstPlayerId); RefreshPersistentSubscriptionsForPlayer(secondPlayerId); ClientRelationshipDTO firstView = RelationshipDB.GetClientRelationship( firstPlayerId, secondPlayerId) ?? CreateNoClientRelationship(secondPlayerId); ClientRelationshipDTO secondView = RelationshipDB.GetClientRelationship( secondPlayerId, firstPlayerId) ?? CreateNoClientRelationship(firstPlayerId); Console.WriteLine( $"[RELATIONSHIP LIVE] reason={reason} " + $"first={firstPlayerId}->{secondPlayerId} " + $"type={firstView.RelationshipType} " + $"ignored={firstView.Ignored} muted={firstView.Muted} " + $"sockets={GetPlayerConnectionCount(firstPlayerId)}"); Console.WriteLine( $"[RELATIONSHIP LIVE] reason={reason} " + $"second={secondPlayerId}->{firstPlayerId} " + $"type={secondView.RelationshipType} " + $"ignored={secondView.Ignored} muted={secondView.Muted} " + $"sockets={GetPlayerConnectionCount(secondPlayerId)}"); await Task.WhenAll( SendPushSignalToPlayerAsync( firstPlayerId, RelationshipsInvalidTarget), SendPushSignalToPlayerAsync( secondPlayerId, RelationshipsInvalidTarget)); await Task.Delay(50); await Task.WhenAll( SendPushEventToPlayerAsync( firstPlayerId, RelationshipChangedTarget, firstView), SendPushEventToPlayerAsync( secondPlayerId, RelationshipChangedTarget, secondView)); Console.WriteLine( $"[RELATIONSHIP LIVE] complete reason={reason} " + $"players={firstPlayerId},{secondPlayerId}"); } private static int GetPlayerConnectionCount(long playerId) => PlayerConnections.TryGetValue(playerId, out var connections) ? connections.Count : 0; public static async Task NotifyPlayerProfileUpdatedAsync(long playerId) { PlayerDTOBase? account = PlayerDB.GetAccountsBulk( new List { playerId }, callerId: null) .FirstOrDefault(); if (account == null) return; string[] ownConnections = PlayerConnections.TryGetValue( playerId, out var ownSockets) ? ownSockets.Keys .Where(id => !string.IsNullOrWhiteSpace(id)) .Distinct(StringComparer.Ordinal) .ToArray() : Array.Empty(); var ownConnectionSet = ownConnections.ToHashSet(StringComparer.Ordinal); string[] viewerConnections = GetProfileRecipientConnectionIds(playerId) .Where(connectionId => !ownConnectionSet.Contains(connectionId)) .Distinct(StringComparer.Ordinal) .ToArray(); Task selfPush = ownConnections.Length == 0 ? Task.CompletedTask : SendProfilePushToConnectionIdsAsync( ownConnections, account, SelfAccountUpdateNotificationId); Task viewerPush = viewerConnections.Length == 0 ? Task.CompletedTask : SendProfilePushToConnectionIdsAsync( viewerConnections, account, AccountUpdateNotificationId); await Task.WhenAll(selfPush, viewerPush); Console.WriteLine( $"[PROFILE PUSH] player={playerId} " + $"selfSockets={ownConnections.Length} viewerSockets={viewerConnections.Length} " + $"displayName={account.displayName} emoji={account.displayEmoji} " + $"profileImage={account.profileImage}"); } public static async Task NotifyPlayerPresenceUpdatedAsync(long playerId) { string[] recipientConnections = GetPresenceRecipientConnectionIds(playerId); if (recipientConnections.Length == 0) return; var connectionsByViewer = recipientConnections .Where(ConnectionPlayers.ContainsKey) .GroupBy(connectionId => ConnectionPlayers[connectionId]) .ToArray(); foreach (IGrouping viewerConnections in connectionsByViewer) { Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat( playerId, viewerConnections.Key); await SendPresencePushToConnectionIdsAsync( viewerConnections, heartbeat); } Heartbeat publicHeartbeat = PlayerDB.GetPlayerHeartbeat(playerId); Console.WriteLine( $"[PRESENCE PUSH] player={playerId} " + $"online={publicHeartbeat.isOnline} " + $"room={publicHeartbeat.roomInstance?.roomId.ToString() ?? "none"} " + $"instance={publicHeartbeat.roomInstance?.roomInstanceId.ToString() ?? "none"} " + $"viewers={connectionsByViewer.Length} " + $"sockets={recipientConnections.Length}"); } public static async Task NotifyProgressionAsync( long playerId, int oldLevel, int newLevel, int xp) { int clientPlayerId = checked((int)playerId); object CreateClientProgression(int level) => new { PlayerId = clientPlayerId, Level = level, XP = xp }; if (newLevel > oldLevel) { await SendPushEventToPlayerAsync( playerId, "PlayerProgressionLevelUpdate", CreateClientProgression(oldLevel)); await Task.Delay(40); } await SendPushEventToPlayerAsync( playerId, "PlayerProgressionLevelUpdate", CreateClientProgression(newLevel)); if (newLevel > oldLevel) { Console.WriteLine( $"[LEVEL EFFECT] player={playerId} " + $"staged={oldLevel}->{newLevel}"); } await SendHubEventToPlayerAsync( playerId, "PlayerProgressionUpdated", clientPlayerId, newLevel, xp); await NotifyPlayerProfileUpdatedAsync(playerId); if (newLevel > oldLevel) { await SendHubEventToPlayerAsync( playerId, "LevelUpNotified", newLevel); } } public static async Task NotifyCheerAsync( long receiverPlayerId, NotificationDB.ClientNotification notification, int category) { await SendPushEventToPlayerAsync( receiverPlayerId, MessageReceivedTarget, notification); await SendHubEventToPlayerAsync( receiverPlayerId, "PlayerCheerReceived", notification.FromPlayerId, category); } public static async Task NotifyConsumableRemovedAsync( long playerId, long mappingId, string consumableItemDesc, string createdAt, int previousCount, int remainingCount) { var payload = new { Id = mappingId, ConsumableItemDesc = consumableItemDesc ?? string.Empty, CreatedAt = createdAt, Count = Math.Max(0, remainingCount), InitialCount = Math.Max(0, previousCount), IsActive = false, ActiveDurationMinutes = 0, IsTransferable = false }; await SendPushEventToPlayerAsync( playerId, ConsumableMappingRemovedTarget, payload); Console.WriteLine( $"[CONSUMABLE PUSH] player={playerId} id={mappingId} " + $"count={remainingCount} initial={previousCount} " + $"eventId={ConsumableMappingRemovedTarget}"); } public static async Task NotifyRoomInviteAsync( long inviterPlayerId, long receiverPlayerId, long roomId, long roomInstanceId, string? photonRoomId) { Heartbeat inviterHeartbeat = PlayerDB.GetPlayerHeartbeat(inviterPlayerId); RoomInstance? inviterInstance = inviterHeartbeat.roomInstance; bool isExactInviterInstance = inviterInstance != null && inviterInstance.roomInstanceId == roomInstanceId && inviterInstance.roomId == roomId; string roomName = isExactInviterInstance && !string.IsNullOrWhiteSpace(inviterInstance!.Name) ? inviterInstance.Name : RoomDB.GetRoom(roomId)?.Name ?? "Room"; bool isPrivateInstance = isExactInviterInstance && Sessions.IsRestrictedInstance(inviterInstance); string resolvedPhotonRoomId = isExactInviterInstance ? inviterInstance!.photonRoomId : photonRoomId ?? string.Empty; NotificationDB.ClientNotification message = NotificationDB.CreateRoomInvite( inviterPlayerId, receiverPlayerId, roomId, roomInstanceId, roomName, inviteMode: isPrivateInstance ? 1 : 0, photonRoomId: resolvedPhotonRoomId); await SendPushEventToPlayerAsync( receiverPlayerId, MessageReceivedTarget, message); Console.WriteLine( $"[ROOM INVITE PUSH] from={inviterPlayerId} " + $"to={receiverPlayerId} messageId={message.Id} " + $"messageType={message.Type} data={message.Message} " + $"roomInstanceId={roomInstanceId} room={roomId} " + $"private={isPrivateInstance.ToString().ToLowerInvariant()} " + $"name={roomName} eventId={MessageReceivedTarget} " + $"idJsonType=string " + $"wireContract=game-invite-v1 " + $"sockets={GetPlayerConnectionCount(receiverPlayerId)}"); return message; } public static async Task NotifyMessageAsync( long receiverPlayerId, NotificationDB.ClientNotification message) { await SendPushEventToPlayerAsync( receiverPlayerId, MessageReceivedTarget, message); } public static async Task NotifyPartyInviteAsync( long inviterPlayerId, long receiverPlayerId, long partyId) { NotificationDB.ClientNotification message = NotificationDB.CreatePartyInvite( inviterPlayerId, receiverPlayerId, partyId); await SendPushEventToPlayerAsync( receiverPlayerId, MessageReceivedTarget, message); await SendHubEventToPlayerAsync( receiverPlayerId, "PartyInviteReceived", message); } public static async Task NotifyPartyUpdatedAsync( IEnumerable memberPlayerIds, object party) { await Task.WhenAll(memberPlayerIds .Where(id => id > 0) .Distinct() .Select(playerId => SendHubEventToPlayerAsync( playerId, "PartyUpdated", party))); } public static async Task NotifyGiftAsync( long senderPlayerId, long receiverPlayerId, object? gift = null, bool immediate = true) { await NotifyGiftsAsync( senderPlayerId, new[] { new GiftLiveDelivery(receiverPlayerId, gift) }, immediate); } public static async Task NotifyGiftsAsync( long senderPlayerId, IEnumerable deliveries, bool immediate = true) { GiftLiveDelivery[] requested = deliveries? .Where(delivery => delivery != null && delivery.ReceiverPlayerId > 0) .ToArray() ?? Array.Empty(); var result = new GiftLiveDeliveryResult { TargetPlayers = requested .Select(delivery => delivery.ReceiverPlayerId) .Distinct() .Count() }; if (requested.Length == 0) return result; PreparedGiftLiveDelivery[] prepared = requested .Select(delivery => { object payload = delivery.Gift is GiftPackage package ? CreateClientGiftPackagePayload( package, senderPlayerId, delivery.ReceiverPlayerId) : delivery.Gift ?? CreateFallbackGiftPackagePayload( senderPlayerId, delivery.ReceiverPlayerId); int effectiveSenderPlayerId = delivery.Gift is GiftPackage sentGift ? NormalizeGiftSenderId( sentGift.FromPlayerId, senderPlayerId, delivery.ReceiverPlayerId) : NormalizeGiftSenderId( 0, senderPlayerId, delivery.ReceiverPlayerId); return new PreparedGiftLiveDelivery { SenderPlayerId = effectiveSenderPlayerId, ReceiverPlayerId = delivery.ReceiverPlayerId, Payload = payload, GiftPackageId = delivery.Gift is GiftPackage sentPackage ? sentPackage.GiftPackageId : 0 }; }) .ToArray(); bool primedAnyProfile = false; foreach (IGrouping senderGroup in prepared.GroupBy(delivery => delivery.SenderPlayerId)) { var connectionOwners = new Dictionary( StringComparer.Ordinal); foreach (PreparedGiftLiveDelivery delivery in senderGroup) { foreach (string connectionId in GetPlayerConnectionIds(delivery.ReceiverPlayerId)) { connectionOwners[connectionId] = delivery; } } if (connectionOwners.Count == 0) continue; PlayerDTOBase? senderAccount = ResolveGiftSenderAccount( senderGroup.Key); if (senderAccount == null) { Console.WriteLine( $"[GIFT SENDER CONTEXT] sender={senderGroup.Key} " + $"profileMissing=true sockets={connectionOwners.Count}"); continue; } int primedSockets = await TrySendProfilePushToConnectionIdsAsync( connectionOwners.Keys, senderAccount, AccountUpdateNotificationId); if (primedSockets > 0) { foreach (KeyValuePair entry in connectionOwners) { entry.Value.PrimedConnectionIds.Add(entry.Key); } primedAnyProfile = true; } Console.WriteLine( $"[GIFT SENDER CONTEXT] sender={senderGroup.Key} " + $"receivers={connectionOwners.Values.Select(value => value.ReceiverPlayerId).Distinct().Count()} " + $"profileEvent={AccountUpdateNotificationId} " + $"sockets={primedSockets} batch=true"); } if (primedAnyProfile) await Task.Delay(75); string eventId = immediate ? GiftPackageReceivedImmediateTarget : GiftPackageReceivedTarget; var livePlayerIds = new HashSet(); foreach (PreparedGiftLiveDelivery delivery in prepared) { string[] connectionIds = GetPlayerConnectionIds(delivery.ReceiverPlayerId); if (connectionIds.Length == 0) { Console.WriteLine( $"[GIFT PUSH] from={senderPlayerId} " + $"to={delivery.ReceiverPlayerId} " + $"effectiveFrom={delivery.SenderPlayerId} " + $"package={delivery.GiftPackageId} eventId={eventId} " + "idJsonType=string senderProfilePrimed=false sockets=0 " + "live=false pendingFallback=true"); continue; } string[] unprimedConnectionIds = connectionIds .Where(connectionId => !delivery.PrimedConnectionIds.Contains(connectionId)) .ToArray(); if (unprimedConnectionIds.Length > 0) { PlayerDTOBase? senderAccount = ResolveGiftSenderAccount( delivery.SenderPlayerId); if (senderAccount != null) { int primedSockets = await TrySendProfilePushToConnectionIdsAsync( unprimedConnectionIds, senderAccount, AccountUpdateNotificationId); if (primedSockets > 0) { foreach (string connectionId in unprimedConnectionIds) { delivery.PrimedConnectionIds.Add(connectionId); } await Task.Delay(50); } } } string jsonArgument = CreateClientNotificationJson( eventId, delivery.Payload); int deliveredSockets = await TrySendPushJsonToConnectionIdsAsync( connectionIds, eventId, jsonArgument); bool deliveredLive = deliveredSockets > 0; if (deliveredLive) { livePlayerIds.Add(delivery.ReceiverPlayerId); result.LiveSockets += deliveredSockets; } bool senderProfilePrimed = connectionIds.Any(connectionId => delivery.PrimedConnectionIds.Contains(connectionId)); Console.WriteLine( $"[GIFT PUSH] from={senderPlayerId} " + $"to={delivery.ReceiverPlayerId} " + $"effectiveFrom={delivery.SenderPlayerId} " + $"package={delivery.GiftPackageId} eventId={eventId} " + $"idJsonType=string senderProfilePrimed={senderProfilePrimed} " + $"sockets={deliveredSockets} live={deliveredLive.ToString().ToLowerInvariant()} " + $"pendingFallback={!deliveredLive}"); } result.LivePlayers = livePlayerIds.Count; result.OfflinePlayers = Math.Max( 0, result.TargetPlayers - result.LivePlayers); Console.WriteLine( $"[GIFT LIVE BATCH] sender={senderPlayerId} " + $"targets={result.TargetPlayers} livePlayers={result.LivePlayers} " + $"liveSockets={result.LiveSockets} offline={result.OfflinePlayers} " + $"eventId={eventId}"); return result; } private static string[] GetPlayerConnectionIds(long playerId) { if (!PlayerConnections.TryGetValue( playerId, out var receiverConnections)) { return Array.Empty(); } return receiverConnections.Keys .Where(id => !string.IsNullOrWhiteSpace(id)) .Where(id => ConnectionPlayers.TryGetValue(id, out long ownerId) && ownerId == playerId) .Distinct(StringComparer.Ordinal) .ToArray(); } private static PlayerDTOBase? ResolveGiftSenderAccount( long senderPlayerId) { return PlayerDB.GetAccountsBulk( new List { senderPlayerId }, callerId: null) .FirstOrDefault(); } public static async Task NotifyGiftConsumedAsync( long receiverPlayerId, long giftId, IEnumerable? remainingGifts = null) { object[] remainingPayloads = remainingGifts? .Select(giftPackage => CreateClientGiftPackagePayload( giftPackage, giftPackage.FromPlayerId, receiverPlayerId)) .ToArray() ?? Array.Empty(); var payload = new { GiftId = giftId, RemainingGifts = remainingPayloads }; await SendPushEventToPlayerAsync( receiverPlayerId, GiftConsumedTarget, payload); Console.WriteLine( $"[GIFT CONSUMED PUSH] player={receiverPlayerId} " + $"gift={giftId} remaining={remainingPayloads.Length} " + $"eventId={GiftConsumedTarget}"); } private static object CreateClientGiftPackagePayload( GiftPackage gift, long senderPlayerId, long receiverPlayerId) { int fromPlayerId = NormalizeGiftSenderId( gift.FromPlayerId, senderPlayerId, receiverPlayerId); int giftContext = NormalizeGiftContext(gift.GiftContext); return new { AvatarItemDesc = EmptyIfWhiteSpace(gift.AvatarItemDesc), AvatarItemType = 0, BalanceType = gift.BalanceType ?? (int)BalanceType.NonPurchasedNotUsableInP2P, ConsumableItemDesc = EmptyIfWhiteSpace(gift.ConsumableItemDesc), Currency = gift.Currency, CurrencyType = gift.CurrencyType, EquipmentModificationGuid = EmptyIfWhiteSpace(gift.EquipmentModificationGuid), EquipmentPrefabName = EmptyIfWhiteSpace(gift.EquipmentPrefabName), FromGiftDropId = 0, FromPlayerId = fromPlayerId, GiftContext = giftContext, GiftRarity = gift.Rarity, Id = gift.GiftPackageId, Level = 0, Message = gift.Message ?? string.Empty, Platform = gift.Platform, PlatformsToSpawnOn = gift.PlatformMask, Xp = gift.XP }; } private static object CreateFallbackGiftPackagePayload( long senderPlayerId, long receiverPlayerId) { int fromPlayerId = NormalizeGiftSenderId( 0, senderPlayerId, receiverPlayerId); return new { AvatarItemDesc = string.Empty, AvatarItemType = 0, BalanceType = (int)BalanceType.NonPurchasedNotUsableInP2P, ConsumableItemDesc = string.Empty, Currency = 0, CurrencyType = 2, EquipmentModificationGuid = string.Empty, EquipmentPrefabName = string.Empty, FromGiftDropId = 0, FromPlayerId = fromPlayerId, GiftContext = 2, GiftRarity = 0, Id = 0L, Level = 0, Message = string.Empty, Platform = -1, PlatformsToSpawnOn = -1, Xp = 0 }; } private static int NormalizeGiftSenderId( int storedSenderPlayerId, long requestedSenderPlayerId, long receiverPlayerId) { long senderPlayerId = storedSenderPlayerId > 0 ? storedSenderPlayerId : requestedSenderPlayerId; if (senderPlayerId <= 0) return 0; if (senderPlayerId > int.MaxValue) { throw new InvalidOperationException( $"Gift sender id {senderPlayerId} cannot be represented by the April 2023 client."); } if (senderPlayerId == receiverPlayerId) return receiverPlayerId == 1 ? 2 : 1; return checked((int)senderPlayerId); } private static int NormalizeGiftContext(int giftContext) => giftContext is (int)PlayerDBClasses.GiftContext.Default or (int)PlayerDBClasses.GiftContext.Store_RecCenter ? (int)PlayerDBClasses.GiftContext.Game_Drop : giftContext; private static string EmptyIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value; private static ClientRelationshipDTO CreateNoClientRelationship( long otherPlayerId) { if (otherPlayerId <= 0 || otherPlayerId > int.MaxValue) { throw new InvalidOperationException( $"Player id {otherPlayerId} cannot be represented by the April 2023 client."); } return new ClientRelationshipDTO { PlayerID = checked((int)otherPlayerId), RelationshipType = (int)RelationshipType.None, Favorited = 0, Muted = 0, Ignored = 0 }; } private static Task SendPushSignalToPlayerAsync( long playerId, string eventId) => SendPushJsonToPlayerAsync( playerId, eventId, CreateClientNotificationJson(eventId, null)); private static Task SendPushEventToPlayerAsync( long playerId, string eventId, object payload) => SendPushJsonToPlayerAsync( playerId, eventId, CreateClientNotificationJson(eventId, payload)); private static Task SendPushEventToConnectionIdsAsync( IEnumerable connectionIds, string eventId, object payload) => SendPushJsonToConnectionIdsAsync( connectionIds, eventId, CreateClientNotificationJson(eventId, payload)); private static Task SendProfilePushToConnectionIdsAsync( IEnumerable connectionIds, PlayerDTOBase account) => SendProfilePushToConnectionIdsAsync( connectionIds, account, AccountUpdateNotificationId); private static async Task SendProfilePushToConnectionIdsAsync( IEnumerable connectionIds, PlayerDTOBase account, string eventId) { await TrySendProfilePushToConnectionIdsAsync( connectionIds, account, eventId); } private static async Task TrySendProfilePushToConnectionIdsAsync( IEnumerable connectionIds, PlayerDTOBase account, string eventId) { string[] ids = connectionIds .Where(id => !string.IsNullOrWhiteSpace(id)) .Distinct(StringComparer.Ordinal) .ToArray(); if (ids.Length == 0) return 0; object profile = CreateClientProfilePayload(account); string jsonArgument = CreateClientNotificationJson( eventId, profile); int deliveredSockets = await TrySendTargetToConnectionsAsync( ids, ClientNotificationHubTarget, new object?[] { jsonArgument }); Console.WriteLine( $"[PROFILE TRANSPORT] player={account.accountId} " + $"hubTarget={ClientNotificationHubTarget} " + $"eventId={eventId} " + $"sockets={deliveredSockets} bytes={jsonArgument.Length}"); return deliveredSockets; } private static object CreateClientProfilePayload(PlayerDTOBase account) { FullPlayer? fullPlayer = PlayerDB.Players.FindById(account.accountId); string? bannerImage = fullPlayer?.Player?.BannerImage; if (string.IsNullOrWhiteSpace(bannerImage)) bannerImage = null; return new { accountId = checked((int)account.accountId), username = account.username ?? string.Empty, displayName = account.displayName ?? string.Empty, profileImage = account.profileImage ?? string.Empty, bannerImage, displayEmoji = account.displayEmoji ?? string.Empty, isJunior = account.isJunior ?? false, platforms = account.platforms, personalPronouns = account.personalPronouns, identityFlags = account.identityFlags, createdAt = account.createdAt }; } private static async Task SendPresencePushToConnectionIdsAsync( IEnumerable connectionIds, Heartbeat heartbeat) { string[] ids = connectionIds .Where(id => !string.IsNullOrWhiteSpace(id)) .Distinct(StringComparer.Ordinal) .ToArray(); if (ids.Length == 0) return; string jsonArgument = CreateClientNotificationJson( PresenceUpdateNotificationId, heartbeat); await SendTargetToConnectionsAsync( ids, ClientNotificationHubTarget, new object?[] { jsonArgument }); Console.WriteLine( $"[PRESENCE TRANSPORT] player={heartbeat.playerId} " + $"hubTarget={ClientNotificationHubTarget} " + $"eventId={PresenceUpdateNotificationId} " + $"sockets={ids.Length} bytes={jsonArgument.Length}"); } private static Task SendPushJsonToPlayerAsync( long playerId, string eventId, string jsonArgument) { if (!PlayerConnections.TryGetValue(playerId, out var connections)) { Console.WriteLine( $"[Push] queued/persisted event={eventId}, " + $"player={playerId}, online=false"); return Task.CompletedTask; } return SendPushJsonToConnectionIdsAsync( connections.Keys, eventId, jsonArgument); } private static async Task SendPushJsonToConnectionIdsAsync( IEnumerable connectionIds, string eventId, string jsonArgument) { await TrySendPushJsonToConnectionIdsAsync( connectionIds, eventId, jsonArgument); } private static async Task TrySendPushJsonToConnectionIdsAsync( IEnumerable connectionIds, string eventId, string jsonArgument) { string[] ids = connectionIds .Where(id => !string.IsNullOrWhiteSpace(id)) .Distinct(StringComparer.Ordinal) .ToArray(); if (ids.Length == 0) return 0; int deliveredSockets = await TrySendTargetToConnectionsAsync( ids, ClientNotificationHubTarget, new object?[] { jsonArgument }); string readableEvent = PushTargetNames.TryGetValue( eventId, out string? eventName) ? $"{eventId} ({eventName})" : eventId; Console.WriteLine( $"[Push] hubTarget={ClientNotificationHubTarget}, " + $"event={readableEvent}, sockets={deliveredSockets}, " + $"idJsonType=string, bytes={jsonArgument.Length}"); return deliveredSockets; } private static string NormalizeClientNotificationId(string eventId) { if (string.Equals( eventId, "11", StringComparison.Ordinal) || string.Equals( eventId, "SubscriptionUpdateProfile", StringComparison.Ordinal)) { return AccountUpdateNotificationId; } if (string.Equals( eventId, SubscriptionUpdatePresenceTarget, StringComparison.Ordinal) || string.Equals( eventId, "SubscriptionUpdatePresence", StringComparison.Ordinal)) { return PresenceUpdateNotificationId; } foreach (KeyValuePair entry in PushTargetNames) { if (string.Equals( entry.Value, eventId, StringComparison.Ordinal)) { return entry.Key; } } return eventId; } private static string CreateClientNotificationJson( string eventId, object? message) { string normalizedEventId = NormalizeClientNotificationId(eventId); return message == null ? SystemJsonSerializer.Serialize(new { Id = normalizedEventId }) : SystemJsonSerializer.Serialize(new { Id = normalizedEventId, Msg = message }); } public static Task NotifyRoomUpdatedAsync( long recipientPlayerId, object roomUpdate) => SendPushEventToPlayerAsync( recipientPlayerId, RoomUpdateNotificationId, roomUpdate); private static async Task SendTargetToConnectionsAsync( IEnumerable connectionIds, string target, object?[] arguments) { await TrySendTargetToConnectionsAsync( connectionIds, target, arguments); } private static async Task TrySendTargetToConnectionsAsync( IEnumerable connectionIds, string target, object?[] arguments) { IHubContext? hubContext = _hubContext; if (hubContext == null) { Console.WriteLine( $"[SignalR] Tried to send {target} before broadcaster initialization"); return 0; } string[] ids = connectionIds .Where(id => !string.IsNullOrWhiteSpace(id)) .Where(ConnectionPlayers.ContainsKey) .Distinct(StringComparer.Ordinal) .ToArray(); if (ids.Length == 0) return 0; try { await hubContext.Clients .Clients(ids) .SendCoreAsync( target, arguments, CancellationToken.None); return ids.Length; } catch (Exception ex) { Console.WriteLine( $"[SignalR] Send target={target} failed: {ex.Message}"); return 0; } } private static bool TryGetPropertyIgnoreCase( JsonElement element, string propertyName, out JsonElement value) { foreach (JsonProperty property in element.EnumerateObject()) { if (string.Equals( property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } value = default; return false; } } [ApiController] [Mocha2023.Classes.ApiProtection] [Route("/noti/debug/v1")] public sealed class NotiDebugController : ControllerBase { [HttpGet("status")] public IActionResult Status() { long? playerId = AuthStuff.GetPlayerId(Request); if (!playerId.HasValue) return Unauthorized(); return Ok(NotiController.GetDebugStatus(playerId.Value)); } } }