diff --git a/Client/Components/App.razor b/Client/Components/App.razor
index ace1538..58abcc1 100644
--- a/Client/Components/App.razor
+++ b/Client/Components/App.razor
@@ -20,6 +20,7 @@
+
+
+@code{
+ private bool IsLoading { get; set; } = true;
+ private LeaderboardData? LeaderboardData { get; set; } = null;
+
+ protected override async Task OnInitializedAsync()
+ {
+ // Get the time range from the query string
+ var uri = new Uri(NavigationManager.Uri);
+ var query = uri.Query;
+ var timeRange = 5; // Default to AllTime
+ if (!string.IsNullOrEmpty(query))
+ {
+ var queryDictionary = System.Web.HttpUtility.ParseQueryString(query);
+ if (queryDictionary.AllKeys.Contains("timeRange"))
+ {
+ timeRange = int.Parse(queryDictionary["timeRange"]);
+ }
+ }
+
+ LeaderboardData = await Http.GetFromJsonAsync("api/Data/leaderboard?rangeOption=" + timeRange);
+ IsLoading = false;
+ }
+}
\ No newline at end of file
diff --git a/Client/Components/SearchBar.razor b/Client/Components/SearchBar.razor
index f3a20b9..bc402ed 100644
--- a/Client/Components/SearchBar.razor
+++ b/Client/Components/SearchBar.razor
@@ -66,7 +66,8 @@
document.querySelector('.search-bar input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
- search(0);
+ //search(0);
+ //Temporary remove since autocomplete uses enter to select
}
});
diff --git a/Server/Api/DataController.cs b/Server/Api/DataController.cs
index 6734c71..4411e7a 100644
--- a/Server/Api/DataController.cs
+++ b/Server/Api/DataController.cs
@@ -1,9 +1,12 @@
using System.Collections.Concurrent;
+using System.Diagnostics;
using System.Net.WebSockets;
using System.Text;
+using System.Text.Json;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
using Serilog;
using Shared;
@@ -18,13 +21,16 @@ namespace Server.Api;
public class DataController : ControllerBase
{
private readonly ReplayDbContext _context;
+ private readonly IMemoryCache _cache;
+
public static readonly Dictionary ConnectedUsers = new();
private Timer _timer;
- public DataController(ReplayDbContext context)
+ public DataController(ReplayDbContext context, IMemoryCache cache)
{
_context = context;
_timer = new Timer(CheckInactiveConnections, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
+ _cache = cache;
}
///
@@ -47,7 +53,125 @@ [FromQuery] string username
return Ok(completions);
}
+
+ [HttpGet]
+ [Route("leaderboard")]
+ public async Task GetLeaderboard(
+ [FromQuery] RangeOption rangeOption = RangeOption.AllTime
+ )
+ {
+ // First, try to get the leaderboard from the cache
+ if (_cache.TryGetValue("leaderboard-" + rangeOption, out LeaderboardData leaderboardData))
+ {
+ return leaderboardData;
+ }
+
+ var rangeTimespan = rangeOption.GetTimeSpan();
+ var dataReplays = await _context.Replays
+ .Where(r => r.Date > DateTime.UtcNow - rangeTimespan)
+ .Include(r => r.RoundEndPlayers)
+ .ToListAsync();
+
+ var leaderboardResult = new LeaderboardData()
+ {
+ MostSeenPlayers = new Dictionary(),
+ MostAntagPlayers = new Dictionary()
+ };
+
+ // To calculate the most seen player, we just count how many times we see a player in each RoundEndPlayer list.
+ // Importantly, we need to filter out in RoundEndPlayers for distinct players since players can appear multiple times there.
+ foreach (var dataReplay in dataReplays)
+ {
+ var distinctBy = dataReplay.RoundEndPlayers.DistinctBy(x => x.PlayerGuid);
+
+ #region Most seen
+
+ foreach (var player in distinctBy)
+ {
+ var playerKey = new PlayerData()
+ {
+ PlayerGuid = player.PlayerGuid,
+ Username = "" // Will be filled in later (god im so sorry PJB)
+ };
+
+ var didAdd = leaderboardResult.MostSeenPlayers.TryAdd(playerKey.PlayerGuid.ToString(), new PlayerCount()
+ {
+ Count = 1,
+ Player = playerKey,
+ });
+ if (!didAdd)
+ {
+ // If the player already exists in the dictionary, we just increment the count.
+ leaderboardResult.MostSeenPlayers[playerKey.PlayerGuid.ToString()].Count++;
+ }
+ }
+
+ #endregion
+
+ #region Most seen as antag
+
+ foreach (var dataReplayRoundEndPlayer in dataReplay.RoundEndPlayers)
+ {
+ if (!dataReplayRoundEndPlayer.Antag)
+ continue;
+
+ var playerKey = new PlayerData()
+ {
+ PlayerGuid = dataReplayRoundEndPlayer.PlayerGuid,
+ Username = ""
+ };
+ var didAdd = leaderboardResult.MostAntagPlayers.TryAdd(playerKey.PlayerGuid.ToString(), new PlayerCount()
+ {
+ Player = playerKey,
+ Count = 1,
+ });
+ if (!didAdd)
+ {
+ leaderboardResult.MostAntagPlayers[playerKey.PlayerGuid.ToString()].Count++;
+ }
+ }
+
+ #endregion
+
+ // TODO: Implement most hunted player
+ }
+
+ // Need to only return the top 10 players
+ leaderboardResult.MostSeenPlayers = leaderboardResult.MostSeenPlayers
+ .OrderByDescending(p => p.Value.Count)
+ .Take(10)
+ .ToDictionary(p => p.Key, p => p.Value);
+
+ leaderboardResult.MostAntagPlayers = leaderboardResult.MostAntagPlayers
+ .OrderByDescending(p => p.Value.Count)
+ .Take(10)
+ .ToDictionary(p => p.Key, p => p.Value);
+
+ // Now we need to fetch the usernames for the players
+ foreach (var player in leaderboardResult.MostSeenPlayers)
+ {
+ var playerData = await FetchPlayerDataFromGuid(player.Value.Player.PlayerGuid);
+ player.Value.Player.Username = playerData.Username;
+ await Task.Delay(50); // Rate limit the API
+ }
+
+ foreach (var player in leaderboardResult.MostAntagPlayers)
+ {
+ var playerData = await FetchPlayerDataFromGuid(player.Value.Player.PlayerGuid);
+ player.Value.Player.Username = playerData.Username;
+ await Task.Delay(50); // Rate limit the API
+ }
+
+ // Save leaderboard to cache (its expensive as fuck to calculate)
+ var cacheEntryOptions = new MemoryCacheEntryOptions()
+ .SetAbsoluteExpiration(TimeSpan.FromHours(3));
+ _cache.Set("leaderboard-" + rangeOption, leaderboardResult, cacheEntryOptions);
+
+
+ return leaderboardResult;
+ }
+ [HttpGet] // this is kind of stupid? swagger does not work without having a method identifier or something
[Route("/ws")]
public async Task Connect()
{
@@ -87,6 +211,46 @@ private async Task Echo(WebSocket webSocket, Guid userId)
ConnectedUsers.Remove(userId, out _);
await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
}
+
+ private async Task FetchPlayerDataFromGuid(Guid guid)
+ {
+ if (!_cache.TryGetValue(guid.ToString(), out PlayerData? playerKey))
+ {
+ playerKey = new PlayerData()
+ {
+ PlayerGuid = guid
+ };
+
+ HttpResponseMessage response = null;
+ try
+ {
+ var httpClient = new HttpClient();
+ response = await httpClient.GetAsync($"https://central.spacestation14.io/auth/api/query/userid?userid={playerKey.PlayerGuid}");
+ response.EnsureSuccessStatusCode();
+ var responseString = await response.Content.ReadAsStringAsync();
+ var username = JsonSerializer.Deserialize(responseString).userName;
+ playerKey.Username = username;
+ }
+ catch (Exception e)
+ {
+ Log.Error("Unable to fetch username for player with GUID {PlayerGuid}: {Error}", playerKey.PlayerGuid, e.Message);
+ if (e.Message.Contains("'<' is an")) // This is a hacky way to check if we got sent a website.
+ {
+ // Probably got sent a website? Log full response.
+ Log.Error("Website might have been sent: {Response}", response?.Content.ReadAsStringAsync().Result);
+ }
+
+ playerKey.Username = "Unable to fetch username (API error)";
+ }
+
+ var cacheEntryOptions = new MemoryCacheEntryOptions()
+ .SetAbsoluteExpiration(TimeSpan.FromMinutes(60));
+
+ _cache.Set(guid.ToString(), playerKey, cacheEntryOptions);
+ }
+
+ return playerKey;
+ }
private void CheckInactiveConnections(object state)
{
diff --git a/Server/Program.cs b/Server/Program.cs
index e6b6bf6..dd3a588 100644
--- a/Server/Program.cs
+++ b/Server/Program.cs
@@ -63,6 +63,8 @@
{
options.MessageTemplate = "Handled {RequestPath}";
});
+
+ builder.Services.AddMemoryCache();
builder.Services.AddMvc();
var app = builder.Build();
diff --git a/Shared/LeaderboardData.cs b/Shared/LeaderboardData.cs
new file mode 100644
index 0000000..4768c04
--- /dev/null
+++ b/Shared/LeaderboardData.cs
@@ -0,0 +1,18 @@
+namespace Shared;
+
+public class LeaderboardData
+{
+ public Dictionary MostSeenPlayers { get; set; }
+ public Dictionary MostAntagPlayers { get; set; }
+
+ ///
+ /// Most times as a kill or maroon target.
+ ///
+ public Dictionary MostHuntedPlayer { get; set; }
+}
+
+public class PlayerCount
+{
+ public PlayerData Player { get; set; }
+ public int Count { get; set; }
+}
\ No newline at end of file
diff --git a/Shared/PlayerData.cs b/Shared/PlayerData.cs
new file mode 100644
index 0000000..0f640c1
--- /dev/null
+++ b/Shared/PlayerData.cs
@@ -0,0 +1,27 @@
+namespace Shared;
+
+///
+/// Represents collected player data from replays. This is used to generate leaderboards and other statistics.
+///
+public class PlayerData
+{
+ public Guid PlayerGuid { get; set; }
+
+ public string Username { get; set; }
+
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not PlayerData other)
+ {
+ return false;
+ }
+
+ return PlayerGuid == other.PlayerGuid;
+ }
+
+ public override int GetHashCode()
+ {
+ return PlayerGuid.GetHashCode();
+ }
+}
\ No newline at end of file
diff --git a/Shared/RangeOption.cs b/Shared/RangeOption.cs
new file mode 100644
index 0000000..bc28226
--- /dev/null
+++ b/Shared/RangeOption.cs
@@ -0,0 +1,54 @@
+namespace Shared;
+
+///
+/// Supported ranges for date-based queries.
+///
+public enum RangeOption
+{
+ ///
+ /// The last 24 hours.
+ ///
+ Last24Hours = 0,
+
+ ///
+ /// The last 7 days.
+ ///
+ Last7Days = 1,
+
+ ///
+ /// The last 30 days.
+ ///
+ Last30Days = 2,
+
+ ///
+ /// The last three months.
+ ///
+ Last90Days = 3,
+
+ ///
+ /// The last year.
+ ///
+ Last365Days = 4,
+
+ ///
+ /// All time.
+ ///
+ AllTime = 5
+}
+
+public static class RangeOptionExtensions
+{
+ public static TimeSpan GetTimeSpan(this RangeOption rangeOption)
+ {
+ return rangeOption switch
+ {
+ RangeOption.Last24Hours => TimeSpan.FromHours(24),
+ RangeOption.Last7Days => TimeSpan.FromDays(7),
+ RangeOption.Last30Days => TimeSpan.FromDays(30),
+ RangeOption.Last90Days => TimeSpan.FromDays(90),
+ RangeOption.Last365Days => TimeSpan.FromDays(365),
+ RangeOption.AllTime => TimeSpan.FromDays(365 * 100), // 100 years, should be enough
+ _ => throw new ArgumentOutOfRangeException(nameof(rangeOption), rangeOption, null)
+ };
+ }
+}
\ No newline at end of file
diff --git a/Shared/UsernameResponse.cs b/Shared/UsernameResponse.cs
new file mode 100644
index 0000000..abdf974
--- /dev/null
+++ b/Shared/UsernameResponse.cs
@@ -0,0 +1,12 @@
+namespace Shared;
+
+public class UsernameResponse
+{
+ public string userName { get; set; }
+
+ public string userId { get; set; }
+
+ public string? patronTier { get; set; }
+
+ public DateTime createdTime { get; set; }
+}
\ No newline at end of file