Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.RecNet.Notifications
{
public class Announcement
{
public long AnnouncementId { get; set; }
public int AnnouncementStatus { get; set; }
public int AnnouncementType { get; set; }
public string Title { get; set; } = null!;
public string Body { get; set; } = null!;
public string ImageName { get; set; } = null!;
public bool IsMostRecent { get; set; }
public string LinkName { get; set; } = null!;
public int LinkType { get; set; }
public string LinkUri { get; set; } = null!;
public Platforms Platform { get; set; }
public DateTime CreatedAt { get; set; }
}
}
@@ -0,0 +1,18 @@
namespace RadicadeServer.RecNet.Notifications
{
public static class AnnouncementBroadcast
{
public const string UpdateNotificationId = "AnnouncementUpdate";
public static Notification Update(Announcement announcement)
=> new() { Id = UpdateNotificationId, Msg = announcement };
public const string DeleteNotificationId = "AnnouncementDelete";
public static Notification Delete(Announcement announcement)
=> new() { Id = DeleteNotificationId, Msg = announcement };
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace RadicadeServer.RecNet.Notifications
{
public class HubClient
{
public long AccountId { get; set; }
public string ConnectionId { get; set; } = string.Empty;
public List<long> Subscriptions { get; set; } = new();
public object SubscriptionsLock { get; } = new();
public bool HasSubscriptions { get; set; }
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace RadicadeServer.RecNet.Notifications
{
public class Notification
{
private object id = null!;
public object Id
{
get => id is System.Enum value
? System.Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture)
.ToString(System.Globalization.CultureInfo.InvariantCulture)
: id;
set => id = value;
}
public object? Msg { get; set; }
}
}
+482
View File
@@ -0,0 +1,482 @@
using Microsoft.AspNetCore.SignalR;
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Messages;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.Utils;
namespace RadicadeServer.API.Notifications
{
public static class Notify
{
public static IDbContextFactory<RecDBContext> DbFactory = null!;
public static IHubContext<NotifyHub> Context = null!;
public static List<HubClient> Clients = new List<HubClient>();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, HubCallerContext> _contexts = new();
public static void RegisterContext(HubCallerContext context)
{
_contexts[context.ConnectionId] = context;
}
public static void ForgetContext(string connectionId)
{
_contexts.TryRemove(connectionId, out _);
}
public static readonly object ClientsLock = new();
public static HubClient? GetClient(long accountId)
{
lock (ClientsLock)
return Clients.FirstOrDefault(n => n.AccountId == accountId);
}
public static HubClient? GetConnectionClient(string connectionId)
{
lock (ClientsLock)
return Clients.FirstOrDefault(n => n.ConnectionId == connectionId);
}
public static int ConnectedConnectionCount
{
get { lock (ClientsLock) return Clients.Count; }
}
public static List<long> ConnectedAccountIds()
{
lock (ClientsLock)
return Clients.Select(n => n.AccountId).Distinct().ToList();
}
public static List<HubClient> SnapshotClients()
{
lock (ClientsLock)
{
return Clients.ToList();
}
}
public static async Task SendMessage(long TargetId, Message msg, string? idFormOverride = null,
Action<Message>? prepareWithId = null)
{
if (DbFactory == null)
{
return;
}
await using var ctx = await DbFactory.CreateDbContextAsync();
msg.SentTime = DateTime.UtcNow;
msg.ToPlayerId = TargetId;
var originalId = msg.Id;
await ctx.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
await using var attempt = await DbFactory.CreateDbContextAsync();
msg.Id = originalId;
await using var transaction = prepareWithId != null && attempt.Database.IsRelational()
? await attempt.Database.BeginTransactionAsync() : null;
await attempt.Messages.AddAsync(msg);
await attempt.SaveChangesAsync();
if (prepareWithId != null)
{
prepareWithId(msg);
await attempt.SaveChangesAsync();
}
if (transaction != null) await transaction.CommitAsync();
});
List<HubClient> clients;
lock (ClientsLock)
clients = Notify.Clients.Where(n => n.AccountId == TargetId).ToList();
var notif = new Notification { Id = NotificationType.MessageReceived, Msg = msg };
if (clients.Count == 0)
{
return;
}
foreach (var client in clients)
await SendToConnection(client.ConnectionId, notif, idFormOverride);
}
public static async Task<int> SendUntilGoneAsync(long accountId, Notification message, TimeSpan window, TimeSpan interval, Notification? alternate = null)
{
if (window <= TimeSpan.Zero) window = TimeSpan.FromSeconds(8);
if (interval <= TimeSpan.Zero) interval = TimeSpan.FromSeconds(1);
var deadline = DateTime.UtcNow + window;
var pushes = 0;
while (true)
{
List<HubClient> clients;
lock (ClientsLock)
clients = Clients.Where(n => n.AccountId == accountId).ToList();
if (clients.Count == 0)
{
return pushes;
}
pushes++;
var frame = alternate != null && pushes % 2 == 0 ? alternate : message;
foreach (var client in clients)
await SendToConnection(client.ConnectionId, frame);
if (DateTime.UtcNow >= deadline)
{
return pushes;
}
await Task.Delay(interval);
}
}
private static string PayloadProbe(Notification frame)
{
try
{
if (frame.Msg == null) return "null";
if (frame.Msg is string s) return s.Length == 0 ? "empty string" : "string";
return frame.Msg.GetType().Name switch
{
"JsonElement" => "object",
_ => frame.Msg.GetType().Name
};
}
catch { return "unknown"; }
}
public static Notification EmptyObjectPayload(NotificationType id)
=> new() { Id = id, Msg = JsonDocument.Parse("{}").RootElement.Clone() };
public static Notification EmptyObjectPayload(string id)
=> new() { Id = id, Msg = JsonDocument.Parse("{}").RootElement.Clone() };
public static void SendReliably(long accountId, Notification message, TimeSpan window, TimeSpan interval, Notification? alternate = null)
{
_ = Task.Run(async () =>
{
try { await SendUntilGoneAsync(accountId, message, window, interval, alternate); }
catch (Exception) { }
});
}
public static int DisconnectAccount(long accountId, string reason)
{
List<string> connectionIds;
lock (ClientsLock)
connectionIds = Clients.Where(n => n.AccountId == accountId).Select(n => n.ConnectionId).ToList();
var closed = 0;
foreach (var connectionId in connectionIds)
{
if (!_contexts.TryRemove(connectionId, out var context)) continue;
try
{
context.Abort();
closed++;
}
catch (Exception)
{
}
}
return closed;
}
public static async Task SendNotify(long AccountId, Notification Message)
{
List<HubClient> clients;
lock (ClientsLock)
clients = Notify.Clients.Where(n => n.AccountId == AccountId).ToList();
if (clients.Count == 0)
{
return;
}
foreach (var client in clients)
await SendToConnection(client.ConnectionId, Message);
}
public static async Task SendSelfAccountUpdate(long accountId)
{
if (DbFactory == null)
{
return;
}
await using var ctx = await DbFactory.CreateDbContextAsync();
var profile = await ctx.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId);
if (profile == null) return;
var plrData = await ctx.Players.FirstOrDefaultAsync(x => x.AccountId == accountId);
if (plrData == null) return;
await SendNotify(accountId, new Notification
{
Id = "SelfAccountUpdate",
Msg = PlayerUtils.BuildSelfAccount(profile, plrData)
});
}
public static async Task<Presence?> BuildSelfPresenceAsync(RecDBContext ctx, long accountId)
{
var realPresence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == accountId);
if (realPresence == null) return null;
RoomInstance? roomInstance = realPresence.roomInstance is > 0
? await ctx.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == realPresence.roomInstance)
: null;
return await Presence.CreateAsync(ctx, realPresence, roomInstance);
}
public static async Task SendGlobalNotify(Notification Message)
{
if (Notify.Context == null)
{
return;
}
await Notify.Context.Clients.All.SendAsync("Notification", Message.ToJson());
}
private static async Task SendToConnection(string connectionId, Notification Message, string? idFormOverride = null)
{
if (Notify.Context == null)
{
return;
}
await Notify.Context.Clients.Client(connectionId).SendAsync("Notification",
idFormOverride == null ? Message.ToJson() : EnvelopeWithIdForm(Message, idFormOverride));
}
public static string EnvelopeWithIdForm(Notification message, string idJson)
=> $"{{\"Id\":{idJson},\"Msg\":{message.Msg?.ToJson() ?? "null"}}}";
public static string IdFormJson(string? form, NotificationType type)
{
var number = Convert.ToInt64(type, CultureInfo.InvariantCulture)
.ToString(CultureInfo.InvariantCulture);
return (form ?? "string").Trim().ToLowerInvariant() switch
{
"number" => number,
"name" => JsonSerializer.Serialize(type.ToString()),
_ => JsonSerializer.Serialize(number),
};
}
public static async Task<string> ReplayStoredCoachMessageAsync(long accountId, string? form)
{
if (DbFactory == null) return "no database configured";
await using var ctx = await DbFactory.CreateDbContextAsync();
var stored = await ctx.Messages
.Where(m => m.ToPlayerId == accountId && m.Type == MessageType.CoachMessage)
.OrderByDescending(m => m.Id)
.FirstOrDefaultAsync();
if (stored == null)
return $"account {accountId} has no stored coach message - send one from the admin panel first, then replay it.";
List<HubClient> clients;
lock (ClientsLock)
clients = Notify.Clients.Where(n => n.AccountId == accountId).ToList();
if (clients.Count == 0)
return $"account {accountId} has {connectedClause()} but no notify hub connection; nothing to push to. " +
"Check the [NotifyHub] connect line in server-console.log.";
var notif = new Notification { Id = NotificationType.MessageReceived, Msg = stored };
var idJson = IdFormJson(form, NotificationType.MessageReceived);
foreach (var client in clients)
await SendToConnection(client.ConnectionId, notif, idJson);
var wire = EnvelopeWithIdForm(notif, idJson);
return $"replayed stored coach message {stored.Id} to account {accountId} on {clients.Count} connection(s).\n" +
$"Id token on the wire: {idJson}\n" +
$"envelope: {wire}\n" +
ExpectedClientReaction(idJson, NotificationType.MessageReceived);
}
private static string connectedClause()
{
var accounts = ConnectedAccountIds();
return accounts.Count == 0
? "no notify hub connections at all"
: $"{accounts.Count} account(s) connected ({string.Join(", ", accounts)})";
}
private static string ExpectedClientReaction(string idJson, NotificationType type)
{
var number = Convert.ToInt64(type, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture);
if (idJson == number)
return "expected: the client logs a hub-frame parse failure and handles nothing - this is NOT a fix.";
if (idJson.Trim('"') == type.ToString())
return "expected: the client parses the frame, finds no handler under that name, and silently ignores it.";
return $"expected: the client handles it as {type} ({number}) - the message appears on the watch without a relog.";
}
public static string WireId(object? id)
{
try { return JsonSerializer.Serialize(id); }
catch { return id?.ToString() ?? "null"; }
}
public static async Task SendAccountUpdate(long accountId)
{
if (DbFactory == null) return;
await using var ctx = await DbFactory.CreateDbContextAsync();
var profile = await ctx.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId);
if (profile == null) return;
await SendNotify(accountId, new Notification { Id = "AccountUpdate", Msg = profile });
await UpdateSubscriptions(accountId, Notifs.SubscriptionUpdateProfile, profile);
await SendSelfAccountUpdate(accountId);
}
public static Task UpdateEveryone(long playerId) => UpdateSubscriptions(playerId);
public static async Task UpdateSubscriptions(long playerId)
{
if (DbFactory == null) return;
var subscribers = GetSubscriberConnections(playerId);
if (subscribers.Count == 0) return;
var factory = DbFactory;
await Task.WhenAll(subscribers.Select(async subscriber =>
{
try
{
await using var ctx = await factory.CreateDbContextAsync();
await SendStatusTo(ctx, playerId, subscriber);
}
catch (Exception)
{
}
}));
}
public static async Task UpdateSubscriptionsForClient(string connectionId)
{
if (DbFactory == null) return;
var client = GetConnectionClient(connectionId);
if (client == null) return;
long[] subscriptions;
lock (client.SubscriptionsLock)
subscriptions = client.HasSubscriptions ? client.Subscriptions.Distinct().ToArray() : Array.Empty<long>();
if (subscriptions.Length == 0) return;
await using var ctx = await DbFactory.CreateDbContextAsync();
foreach (var playerId in subscriptions)
await SendStatusTo(ctx, playerId, client);
}
public static async Task UpdateSubscriptions(long playerId, object id, object message)
{
var subscribers = GetSubscriberConnections(playerId);
var wireId = id is Notifs.SubscriptionUpdateProfile
? "AccountUpdate"
: id;
var notification = new Notification { Id = wireId, Msg = message };
if (notification.Id is "AccountUpdate")
await UpdateSubscriptions(playerId);
foreach (var subscriber in subscribers)
await SendToConnection(subscriber.ConnectionId, notification);
}
public static async Task SendToSubscribers(long playerId, Notification message)
{
foreach (var subscriber in GetSubscriberConnections(playerId))
await SendToConnection(subscriber.ConnectionId, message);
}
private static List<HubClient> GetSubscriberConnections(long playerId)
{
return SnapshotClients().Where(client =>
{
lock (client.SubscriptionsLock)
return client.HasSubscriptions && client.Subscriptions.Contains(playerId);
}).ToList();
}
private static async Task SendStatusTo(RecDBContext ctx, long fromPlayerId, HubClient recipient)
{
var realPresence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == fromPlayerId);
if (realPresence == null) return;
RoomInstance? roomInstance = realPresence.roomInstance is > 0
? await ctx.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == realPresence.roomInstance)
: null;
roomInstance = await RoomUtils.ForViewerAsync(roomInstance, recipient.AccountId);
if (roomInstance != null)
{
roomInstance = roomInstance.Copy();
roomInstance.photonRegion = string.Empty;
roomInstance.photonRoomId = string.Empty;
roomInstance.dataBlob = string.Empty;
roomInstance.roomCode = string.Empty;
}
var playerPresence = await Presence.CreateAsync(ctx, realPresence, roomInstance);
playerPresence.ForViewer(recipient.AccountId);
await SendToConnection(recipient.ConnectionId, new Notification
{
Id = "PresenceUpdate",
Msg = playerPresence
});
}
}
}
+302
View File
@@ -0,0 +1,302 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.Utils;
namespace RadicadeServer.API.Notifications
{
public class NotifyHub : Hub
{
private static readonly string[] TokenCarriers = { "access_token", "accessToken", "token", "id_token" };
private RecDBContext db;
public NotifyHub(IHubContext<NotifyHub> hubContext, RecDBContext db) {
Notify.Context = hubContext;
this.db = db;
}
private string? RawToken()
{
var httpContext = Context.GetHttpContext();
if (httpContext == null) return null;
foreach (var name in TokenCarriers)
{
var fromQuery = httpContext.Request.Query[name].ToString();
if (!string.IsNullOrWhiteSpace(fromQuery)) return fromQuery;
}
var header = httpContext.Request.Headers["Authorization"].ToString();
if (!string.IsNullOrWhiteSpace(header)) return header;
foreach (var name in TokenCarriers)
{
if (httpContext.Request.Cookies.TryGetValue(name, out var cookie) && !string.IsNullOrWhiteSpace(cookie))
return cookie;
}
return null;
}
private long? GetAccountId()
{
var authenticatedAccount = Context.User?.GetUserId();
if (authenticatedAccount != null) return authenticatedAccount;
var raw = RawToken();
return string.IsNullOrWhiteSpace(raw) ? null : raw.GetUserId();
}
private static JsonElement RawJson(string json) => JsonDocument.Parse(json).RootElement.Clone();
private async Task SendNotificationToCaller(string id, object msg)
{
var notification = new Notification { Id = id, Msg = msg };
await Clients.Caller.SendAsync("Notification", notification.ToJson());
}
private static List<long> ReadPlayerIds(object? update)
{
var ids = new List<long>();
void Add(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var single))
{
ids.Add(single);
return;
}
if (element.ValueKind == JsonValueKind.String && long.TryParse(element.GetString(), out single))
{
ids.Add(single);
return;
}
if (element.ValueKind != JsonValueKind.Array) return;
foreach (var entry in element.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.Number && entry.TryGetInt64(out var value))
ids.Add(value);
else if (entry.ValueKind == JsonValueKind.String && long.TryParse(entry.GetString(), out value))
ids.Add(value);
}
}
try
{
if (update is SubscriptionUpdate subscription && subscription.PlayerIds != null)
{
ids.AddRange(subscription.PlayerIds);
return ids;
}
if (update is not JsonElement json) return ids;
if (json.ValueKind == JsonValueKind.Array)
{
Add(json);
return ids;
}
if (json.ValueKind != JsonValueKind.Object) return ids;
foreach (var property in json.EnumerateObject())
{
if (!property.Name.Equals("PlayerIds", StringComparison.OrdinalIgnoreCase)) continue;
Add(property.Value);
}
}
catch { }
return ids;
}
[HubMethodName("SubscribeToPlayers")]
public Task Subscribe(object? update) => ApplySubscriptions(update);
[HubMethodName("playerSubscriptions/v1/update")]
public Task SubscribeToPlayersV1(object? update) => ApplySubscriptions(update);
private async Task ApplySubscriptions(object? update)
{
long? account = GetAccountId();
if (account == null) return;
var ids = ReadPlayerIds(update);
var client = Notify.GetConnectionClient(Context.ConnectionId);
if (client != null)
{
lock (client.SubscriptionsLock)
{
client.Subscriptions = ids.Where(id => id > 0).Distinct().ToList();
client.HasSubscriptions = true;
}
await Notify.UpdateSubscriptionsForClient(Context.ConnectionId);
}
}
[HubMethodName("SubscribeTo")]
public Task SubscribeToChannels(object? update) => ApplyChannelSubscriptions(update, subscribing: true);
[HubMethodName("UnsubscribeFrom")]
public Task UnsubscribeFromChannels(object? update) => ApplyChannelSubscriptions(update, subscribing: false);
private Task ApplyChannelSubscriptions(object? update, bool subscribing)
{
var channels = ReadChannels(update);
if (channels.Count == 0) return Task.CompletedTask;
return Task.CompletedTask;
}
private static List<string> ReadChannels(object? update)
{
var channels = new List<string>();
try
{
if (update is not JsonElement json || json.ValueKind != JsonValueKind.Object) return channels;
foreach (var property in json.EnumerateObject())
{
if (!property.Name.Equals("Channels", StringComparison.OrdinalIgnoreCase)) continue;
if (property.Value.ValueKind != JsonValueKind.Array) continue;
foreach (var entry in property.Value.EnumerateArray())
{
var channel = entry.ValueKind == JsonValueKind.String ? entry.GetString() : null;
if (!string.IsNullOrWhiteSpace(channel)) channels.Add(channel!);
}
}
}
catch { }
return channels;
}
[HubMethodName("heartbeat2")]
public Task<object?> Heartbeat() => AnswerHeartbeat();
[HubMethodName("heartbeat")]
public Task<object?> HeartbeatUnversioned() => AnswerHeartbeat();
private async Task<object?> AnswerHeartbeat()
{
var account = GetAccountId();
if (account == null) return null;
var presence = await Notify.BuildSelfPresenceAsync(db, account.Value);
if (presence == null) return null;
await SendNotificationToCaller("PresenceUpdate", presence);
return RawJson($"{{\"Id\":{account.Value},\"Msg\":{presence.ToJson()}}}");
}
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
long? account = GetAccountId();
var tokenCarrier = string.IsNullOrWhiteSpace(RawToken()) ? "none" : "present";
if (account != null)
{
Notify.RegisterContext(Context);
lock (Notify.ClientsLock) Notify.Clients.Add(new HubClient
{
AccountId = account.Value,
ConnectionId = Context.ConnectionId
});
var activePresence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == account.Value);
if (activePresence != null && !activePresence.isOnline && activePresence.roomInstance.HasValue)
{
activePresence.isOnline = true;
await db.SaveChangesAsync();
await Notify.UpdateSubscriptions(account.Value);
}
try { await Notify.SendSelfAccountUpdate(account.Value); }
catch (Exception) { }
try
{
var presence = await Notify.BuildSelfPresenceAsync(db, account.Value);
if (presence != null) await SendNotificationToCaller("PresenceUpdate", presence);
}
catch (Exception) { }
try
{
var ban = await db.ResolveBanAsync(account.Value);
if (ban != null)
{
var notification = new Notification { Id = NotificationType.ModerationKick, Msg = await PlayerUtils.BuildBanBlockDetailsAsync(db, ban, account.Value) };
await Clients.Caller.SendAsync("Notification", notification.ToJson());
}
} catch (Exception)
{
}
try
{
var pendingWarning = await db.GetPendingWarningAsync(account.Value);
if (pendingWarning != null)
{
await PlayerUtils.DeliverWarningAsync(account.Value,
PlayerUtils.BuildWarningBlockDetails(pendingWarning, (int)PlayerUtils.WarningScreenHold.TotalSeconds));
}
}
catch (Exception)
{
}
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = GetAccountId();
lock (Notify.ClientsLock) Notify.Clients.RemoveAll(x => x.ConnectionId == Context.ConnectionId);
Notify.ForgetContext(Context.ConnectionId);
if (userId != null)
{
bool stillConnected;
lock (Notify.ClientsLock)
stillConnected = Notify.Clients.Any(x => x.AccountId == userId.Value);
if (!stillConnected)
{
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == userId.Value);
lock (Notify.ClientsLock)
stillConnected = Notify.Clients.Any(x => x.AccountId == userId.Value);
if (presence != null && !stillConnected)
{
presence.isOnline = false;
await db.SaveChangesAsync();
await Notify.UpdateSubscriptions(userId.Value);
}
}
}
await base.OnDisconnectedAsync(exception);
}
}
}
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.SignalR;
namespace RadicadeServer.API.Notifications
{
public sealed class NotifyHubInvocationFilter : IHubFilter
{
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext invocationContext,
Func<HubInvocationContext, ValueTask<object?>> next)
{
try
{
return await next(invocationContext);
}
catch (Exception)
{
return null;
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace RadicadeServer.API.Notifications
{
public static class NotifyHubRoutes
{
public static readonly string[] Paths =
{
"/notify/hub/v1",
"/notify/hub",
"/notifyhub/v1",
"/hub/v1",
"/hub",
};
public static bool IsHubPath(string? path)
{
if (string.IsNullOrEmpty(path)) return false;
foreach (var route in Paths)
{
if (path.Equals(route, StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith(route + "/", StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.API.Notifications;
[ApiController]
[Route("platformnotifications")]
public sealed class PlatformNotificationsAPI : ControllerBase
{
[HttpGet("preferences"), Authorize]
public IActionResult GetPreferences() => Ok(new { email = true, push = true, notifications = true });
[HttpGet("accounts/{accountId:long}/receives/{notificationType}"), Authorize]
public IActionResult Receives(long accountId, string notificationType) => Ok(true);
[HttpGet("crm/me/config"), Authorize]
public IActionResult GetCrmConfig() => Ok(new
{
Results = Array.Empty<object>(),
TotalResults = 0L
});
[HttpGet("config/categories"), Authorize]
public IActionResult GetCategories() => Ok(new
{
Results = Array.Empty<object>(),
TotalResults = 0L
});
}
+69
View File
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Utils;
namespace RadicadeServer.API.Notifications
{
public static class RewardDiag
{
private const int DefaultLogLines = 120;
public static async Task<string> DescribeAsync(RecDBContext db, long accountId, int logLines)
{
var lines = new System.Text.StringBuilder();
lines.AppendLine($"build {BuildInfo.Tag}; assembly published {BuildInfo.AssemblyBuiltUtc:u}; running since {BuildInfo.StartedUtc:u}");
lines.AppendLine($"account {accountId}");
var progression = await db.Progression.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == accountId);
var json = progression?.GetProgressionJson();
var rewards = json?.PendingGameRewards ?? new List<RecNet.Storefront.GameReward>();
var day = json == null ? null : RewardDay.For(json, DateTime.UtcNow);
if (day != null)
lines.AppendLine($"today: dayKey={day.Day} firstActivityPaid={day.FirstActivityPaid} " +
$"dailyChallengePaid={day.DailyChallengePaid} activityBoxes={day.ActivityBoxes}/{RewardDay.ActivityBoxesPerDay}");
lines.AppendLine($"reward selections kept: {rewards.Count} ({rewards.Count(r => !r.Consumed)} outstanding)");
if (rewards.Count == 0)
lines.AppendLine(" (none - no reward has been requested for this account since the ledger was last reset)");
foreach (var reward in rewards.OrderByDescending(r => r.CreatedAt))
{
var state = reward.Consumed
? $"collected box={reward.ConsumedGiftDropId} at {reward.ConsumedAt:u}"
: "OUTSTANDING";
lines.AppendLine($" selectionId={reward.RewardSelectionId} rewardType={reward.RewardType} " +
$"context={RewardTrace.Context(reward.GiftContext)} created={reward.CreatedAt:u} {state}");
lines.AppendLine($" message=\"{reward.Message}\"");
lines.AppendLine($" drop1={RewardTrace.Box(reward.GiftDrop1)}");
lines.AppendLine($" drop2={RewardTrace.Box(reward.GiftDrop2)}");
lines.AppendLine($" drop3={RewardTrace.Box(reward.GiftDrop3)}");
}
var boxes = await StorefrontShared.GetPendingGiftsAsync(db, accountId);
lines.AppendLine($"pending gift boxes: {boxes.Count}");
foreach (var box in boxes)
lines.AppendLine($" {RewardTrace.Box(box)} room={RroRewardPools.RoomForContext(box.Context) ?? "<none>"}");
lines.AppendLine($"game-rewards.log tail (last {logLines} lines):");
try
{
var path = System.IO.Path.Join(DBConfig.PersistentLogs, "game-rewards.log");
if (!System.IO.File.Exists(path))
lines.AppendLine(" (no reward log has been written yet - the reward routes have not been hit)");
else
foreach (var line in System.IO.File.ReadLines(path).TakeLast(Math.Clamp(logLines, 1, 2000)))
lines.AppendLine(" " + line);
}
catch (Exception ex)
{
lines.AppendLine($" (the reward log could not be read: {ex.Message})");
}
return lines.ToString();
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Notifications;
namespace RadicadeServer.API.Notifications
{
public static class RewardPushDiag
{
public const string RegisteredName = "RewardSelectionReceived";
public static string EnumNumber => Convert.ToInt64(NotificationType.GiftPackageRewardSelectionReceived,
System.Globalization.CultureInfo.InvariantCulture)
.ToString(System.Globalization.CultureInfo.InvariantCulture);
public static async Task<string> ReplayPendingRewardSelectionAsync(RecDBContext db, long accountId, string? idForm)
{
var id = Resolve(idForm);
var progression = await db.Progression.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == accountId);
var pending = progression?.GetProgressionJson()?.PendingGameRewards;
var reward = pending?.Where(r => !r.Consumed).OrderByDescending(r => r.CreatedAt).FirstOrDefault();
if (reward == null)
return $"account {accountId} has no pending reward selection - finish a game or a daily challenge first.\n";
var connections = Notify.SnapshotClients().Count(c => c.AccountId == accountId);
var notification = new Notification { Id = id, Msg = reward };
await Notify.SendNotify(accountId, notification);
var idJson = JsonSerializer.Serialize(id);
var lines = new System.Text.StringBuilder();
lines.AppendLine($"account {accountId}: pushed reward selectionId={reward.RewardSelectionId} ({reward.RewardType}, \"{reward.Message}\") on {connections} connection(s).");
lines.AppendLine($"Id token on the wire: {idJson}" + (id == RegisteredName ? " (the key the client registered a handler under)" : string.Empty));
lines.AppendLine($"expected: {(id == EnumNumber ? "the client parses the frame, finds no handler for a bare number unless its own table has one, and handles nothing." : "the client's dispatcher finds a handler under this exact key and the selection screen opens - no relog.")}");
lines.AppendLine($"envelope: {Notify.EnvelopeWithIdForm(notification, idJson)}");
lines.AppendLine($"pending for this account now: {pending!.Count(r => !r.Consumed)} selection(s).");
return lines.ToString();
}
private static string Resolve(string? idForm) => (idForm ?? "name").Trim().ToLowerInvariant() switch
{
"name" => RegisteredName,
"number" => EnumNumber,
"" => RegisteredName,
_ => idForm!.Trim(),
};
}
}
@@ -0,0 +1,7 @@
namespace RadicadeServer.RecNet.Notifications
{
public class SubscriptionUpdate
{
public List<long> PlayerIds { get; set; } = new();
}
}