From 3f19fd77634388f971e9b9b37c23686b4c8e76e2 Mon Sep 17 00:00:00 2001 From: Simon <63975668+Simyon264@users.noreply.github.com> Date: Fri, 12 Apr 2024 19:26:32 +0200 Subject: [PATCH] WIP Leaderboard system Currently 'hidden' to test performance on production. --- Client/Components/App.razor | 1 + Client/Components/Pages/Leaderboard.razor | 90 ++++++++++++ Client/Components/SearchBar.razor | 3 +- Server/Api/DataController.cs | 166 +++++++++++++++++++++- Server/Program.cs | 2 + Shared/LeaderboardData.cs | 18 +++ Shared/PlayerData.cs | 27 ++++ Shared/RangeOption.cs | 54 +++++++ Shared/UsernameResponse.cs | 12 ++ 9 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 Client/Components/Pages/Leaderboard.razor create mode 100644 Shared/LeaderboardData.cs create mode 100644 Shared/PlayerData.cs create mode 100644 Shared/RangeOption.cs create mode 100644 Shared/UsernameResponse.cs 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