Files
mochaisvibecoded 42f7f57b72 :3
2026-08-24 17:00:26 -04:00

2145 lines
76 KiB
C#

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<string> SubscribeToPlayers(
PlayerSubscriptionListDTO? subscriptionList)
{
List<int> requestedPlayerIds =
subscriptionList?.PlayerIds ?? new List<int>();
List<long> 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<string> SubscribeToPlayer(
PlayerSubscriptionListDTO? subscriptionList) =>
SubscribeToPlayers(subscriptionList);
public Task<string> UnsubscribeFromPlayers(
PlayerSubscriptionListDTO? subscriptionList)
{
NotiController.RemovePlayerSubscriptions(
Context.ConnectionId,
subscriptionList?.PlayerIds ?? new List<int>());
return Task.FromResult("200 OK");
}
public Task<string> UnsubscribeFromPlayer(
PlayerSubscriptionListDTO? subscriptionList) =>
UnsubscribeFromPlayers(subscriptionList);
public Task<string> SubscribeToChannels(
ChannelSubscriptionListDTO? subscriptionList)
{
NotiController.ReplaceChannelSubscriptions(
Context.ConnectionId,
subscriptionList?.Channels ?? new List<string>());
return Task.FromResult("200 OK");
}
public Task<string> UnsubscribeFromChannels(
ChannelSubscriptionListDTO? subscriptionList)
{
NotiController.RemoveChannelSubscriptions(
Context.ConnectionId,
subscriptionList?.Channels ?? new List<string>());
return Task.FromResult("200 OK");
}
public Task<string> SubscribeTo(
ChannelSubscriptionListDTO? subscriptionList) =>
SubscribeToChannels(subscriptionList);
public Task<string> UnsubscribeFrom(
ChannelSubscriptionListDTO? subscriptionList) =>
UnsubscribeFromChannels(subscriptionList);
}
public sealed class PlayerSubscriptionListDTO
{
public List<int> PlayerIds { get; set; } = new();
}
public sealed class ChannelSubscriptionListDTO
{
public List<string> 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<string> 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<string, string> PushTargetNames =
new Dictionary<string, string>(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<NotificationHub>? _hubContext;
private static FileSystemWatcher? _announcementWatcher;
private static int _announcementRefreshPending;
private static readonly ConcurrentDictionary<string, long>
ConnectionPlayers = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<long, ConcurrentDictionary<string, byte>>
PlayerConnections = new();
private static readonly ConcurrentDictionary<string, HubCallerContext>
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<string, ConcurrentDictionary<long, byte>>
ConnectionPlayerSubscriptions = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<long, ConcurrentDictionary<string, byte>>
PlayerSubscribers = new();
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>>
ConnectionChannelSubscriptions = new(StringComparer.Ordinal);
public static void Initialize(IHubContext<NotificationHub> 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<string, byte> sockets = PlayerConnections
.GetOrAdd(playerId, _ => new ConcurrentDictionary<string, byte>(
StringComparer.Ordinal));
bool wasOffline = sockets.IsEmpty;
sockets[connectionId] = 0;
ConnectionPlayerSubscriptions.TryAdd(
connectionId,
new ConcurrentDictionary<long, byte>());
ConnectionChannelSubscriptions.TryAdd(
connectionId,
new ConcurrentDictionary<string, byte>(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<long> ReplacePlayerSubscriptions(
string connectionId,
IEnumerable<int> requestedPlayerIds)
{
if (!ConnectionPlayers.TryGetValue(
connectionId,
out long ownerPlayerId))
{
return new List<long>();
}
ConcurrentDictionary<long, byte> current =
ConnectionPlayerSubscriptions.GetOrAdd(
connectionId,
_ => new ConcurrentDictionary<long, byte>());
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<long>();
foreach (long addedPlayerId in requested)
{
if (!current.TryAdd(addedPlayerId, 0))
continue;
newlyAdded.Add(addedPlayerId);
PlayerSubscribers
.GetOrAdd(
addedPlayerId,
_ => new ConcurrentDictionary<string, byte>(
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<int> 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<string> channels)
{
ConcurrentDictionary<string, byte> current =
ConnectionChannelSubscriptions.GetOrAdd(
connectionId,
_ => new ConcurrentDictionary<string, byte>(
StringComparer.Ordinal));
HashSet<string> 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<string> 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<long, byte> current =
ConnectionPlayerSubscriptions.GetOrAdd(
connectionId,
_ => new ConcurrentDictionary<long, byte>());
foreach (long subjectPlayerId in
RelationshipDB.GetPersistentLiveSubjectIds(ownerPlayerId)
.Take(MaxPlayerSubscriptionsPerConnection))
{
if (subjectPlayerId <= 0 ||
subjectPlayerId == ownerPlayerId ||
!current.TryAdd(subjectPlayerId, 0))
{
continue;
}
PlayerSubscribers
.GetOrAdd(
subjectPlayerId,
_ => new ConcurrentDictionary<string, byte>(
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>();
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<int> 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<string> 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<string>(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<string>(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<string>();
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<long>()
}).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<object?>();
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<NotificationDB.ClientNotification> 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<long> { 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<string>();
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<long, string> 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<NotificationDB.ClientNotification>
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<long> 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<GiftLiveDeliveryResult> NotifyGiftsAsync(
long senderPlayerId,
IEnumerable<GiftLiveDelivery> deliveries,
bool immediate = true)
{
GiftLiveDelivery[] requested = deliveries?
.Where(delivery =>
delivery != null &&
delivery.ReceiverPlayerId > 0)
.ToArray() ?? Array.Empty<GiftLiveDelivery>();
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<long, PreparedGiftLiveDelivery> senderGroup in
prepared.GroupBy(delivery => delivery.SenderPlayerId))
{
var connectionOwners = new Dictionary<string, PreparedGiftLiveDelivery>(
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<string, PreparedGiftLiveDelivery> 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<long>();
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<string>();
}
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<long> { senderPlayerId },
callerId: null)
.FirstOrDefault();
}
public static async Task NotifyGiftConsumedAsync(
long receiverPlayerId,
long giftId,
IEnumerable<GiftPackage>? remainingGifts = null)
{
object[] remainingPayloads = remainingGifts?
.Select(giftPackage => CreateClientGiftPackagePayload(
giftPackage,
giftPackage.FromPlayerId,
receiverPlayerId))
.ToArray() ?? Array.Empty<object>();
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<string> connectionIds,
string eventId,
object payload) =>
SendPushJsonToConnectionIdsAsync(
connectionIds,
eventId,
CreateClientNotificationJson(eventId, payload));
private static Task SendProfilePushToConnectionIdsAsync(
IEnumerable<string> connectionIds,
PlayerDTOBase account) =>
SendProfilePushToConnectionIdsAsync(
connectionIds,
account,
AccountUpdateNotificationId);
private static async Task SendProfilePushToConnectionIdsAsync(
IEnumerable<string> connectionIds,
PlayerDTOBase account,
string eventId)
{
await TrySendProfilePushToConnectionIdsAsync(
connectionIds,
account,
eventId);
}
private static async Task<int> TrySendProfilePushToConnectionIdsAsync(
IEnumerable<string> 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<string> 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<string> connectionIds,
string eventId,
string jsonArgument)
{
await TrySendPushJsonToConnectionIdsAsync(
connectionIds,
eventId,
jsonArgument);
}
private static async Task<int> TrySendPushJsonToConnectionIdsAsync(
IEnumerable<string> 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<string, string> 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<string> connectionIds,
string target,
object?[] arguments)
{
await TrySendTargetToConnectionsAsync(
connectionIds,
target,
arguments);
}
private static async Task<int> TrySendTargetToConnectionsAsync(
IEnumerable<string> connectionIds,
string target,
object?[] arguments)
{
IHubContext<NotificationHub>? 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));
}
}
}