Skip to content

Commit

Permalink
WIP Leaderboard system
Browse files Browse the repository at this point in the history
Currently 'hidden' to test performance on production.
  • Loading branch information
Simyon264 committed Apr 12, 2024
1 parent 561aedc commit 3f19fd7
Show file tree
Hide file tree
Showing 9 changed files with 371 additions and 2 deletions.
1 change: 1 addition & 0 deletions Client/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
</head>

<body>

<Routes/>
<script src="_framework/blazor.web.js"></script>
<script>
Expand Down
90 changes: 90 additions & 0 deletions Client/Components/Pages/Leaderboard.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
@page "/leaderboard"
@using Shared
@inject HttpClient Http
@inject NavigationManager NavigationManager
@attribute [StreamRendering]

<h4>Leaderboards</h4>
@if(LeaderboardData == null)
{
<p><em>Loading leaderboard data... Please wait...</em></p>
}
else
{
// 0, 1, 2, 3, 4, 5 = Last24Hours, Last7Days, Last30Days, Last90Days, Last365Days, AllTime
<div class="btn-group" role="group" aria-label="Time range selection">
<button type="button" class="btn btn-secondary" onclick="changeTimeRange(0)">Last 24 hours</button>
<button type="button" class="btn btn-secondary" onclick="changeTimeRange(1)">Last 7 days</button>
<button type="button" class="btn btn-secondary" onclick="changeTimeRange(2)">Last 30 days</button>
<button type="button" class="btn btn-secondary" onclick="changeTimeRange(3)">Last 90 days</button>
<button type="button" class="btn btn-secondary" onclick="changeTimeRange(4)">Last 365 days</button>
<button type="button" class="btn btn-secondary" onclick="changeTimeRange(5)">All time</button>
</div>
// Need 2 tables for MostSeenPlayers and MostAntagPlayers
<table class="table">
<thead>
<tr>
<th>Player name</th>
<th>Times seen</th>
</tr>
</thead>
<tbody>
@foreach (var player in LeaderboardData.MostSeenPlayers)
{
<tr>
<td>@player.Value.Player.Username</td>
<td>@player.Value.Count</td>
</tr>
}
</tbody>
</table>

<table class="table">
<thead>
<tr>
<th>Player name</th>
<th>Times antag</th>
</tr>
</thead>
<tbody>
@foreach (var player in LeaderboardData.MostAntagPlayers)
{
<tr>
<td>@player.Value.Player.Username</td>
<td>@player.Value.Count</td>
</tr>
}
</tbody>
</table>
}

<script>
function changeTimeRange(timeRange) {
// This is a hacky way to change the time range, but it works
window.location.href = "/leaderboard?timeRange=" + timeRange;
}
</script>

@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<LeaderboardData>("api/Data/leaderboard?rangeOption=" + timeRange);
IsLoading = false;
}
}
3 changes: 2 additions & 1 deletion Client/Components/SearchBar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
});
Expand Down
166 changes: 165 additions & 1 deletion Server/Api/DataController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -18,13 +21,16 @@ namespace Server.Api;
public class DataController : ControllerBase
{
private readonly ReplayDbContext _context;
private readonly IMemoryCache _cache;

public static readonly Dictionary<Guid, WebSocket> 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));

Check warning on line 32 in Server/Api/DataController.cs

View workflow job for this annotation

GitHub Actions / deploy

Nullability of reference types in type of parameter 'state' of 'void DataController.CheckInactiveConnections(object state)' doesn't match the target delegate 'TimerCallback' (possibly because of nullability attributes).
_cache = cache;
}

/// <summary>
Expand All @@ -47,7 +53,125 @@ [FromQuery] string username

return Ok(completions);
}

[HttpGet]
[Route("leaderboard")]
public async Task<LeaderboardData> GetLeaderboard(
[FromQuery] RangeOption rangeOption = RangeOption.AllTime
)
{
// First, try to get the leaderboard from the cache
if (_cache.TryGetValue("leaderboard-" + rangeOption, out LeaderboardData leaderboardData))

Check warning on line 64 in Server/Api/DataController.cs

View workflow job for this annotation

GitHub Actions / deploy

Converting null literal or possible null value to non-nullable type.
{
return leaderboardData;

Check warning on line 66 in Server/Api/DataController.cs

View workflow job for this annotation

GitHub Actions / deploy

Possible null reference return.
}

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<string, PlayerCount>(),
MostAntagPlayers = new Dictionary<string, PlayerCount>()
};

// 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);

Check warning on line 85 in Server/Api/DataController.cs

View workflow job for this annotation

GitHub Actions / deploy

Possible null reference argument for parameter 'source' in 'IEnumerable<Player> Enumerable.DistinctBy<Player, Guid>(IEnumerable<Player> source, Func<Player, Guid> keySelector)'.

#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;

Check warning on line 154 in Server/Api/DataController.cs

View workflow job for this annotation

GitHub Actions / deploy

Dereference of a possibly null reference.
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()
{
Expand Down Expand Up @@ -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<PlayerData?> 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<UsernameResponse>(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)
{
Expand Down
2 changes: 2 additions & 0 deletions Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
{
options.MessageTemplate = "Handled {RequestPath}";
});

builder.Services.AddMemoryCache();

builder.Services.AddMvc();
var app = builder.Build();
Expand Down
18 changes: 18 additions & 0 deletions Shared/LeaderboardData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Shared;

public class LeaderboardData
{
public Dictionary<string, PlayerCount> MostSeenPlayers { get; set; }

Check warning on line 5 in Shared/LeaderboardData.cs

View workflow job for this annotation

GitHub Actions / deploy

Non-nullable property 'MostSeenPlayers' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
public Dictionary<string, PlayerCount> MostAntagPlayers { get; set; }

Check warning on line 6 in Shared/LeaderboardData.cs

View workflow job for this annotation

GitHub Actions / deploy

Non-nullable property 'MostAntagPlayers' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

/// <summary>
/// Most times as a kill or maroon target.
/// </summary>
public Dictionary<string, PlayerData> MostHuntedPlayer { get; set; }

Check warning on line 11 in Shared/LeaderboardData.cs

View workflow job for this annotation

GitHub Actions / deploy

Non-nullable property 'MostHuntedPlayer' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}

public class PlayerCount
{
public PlayerData Player { get; set; }

Check warning on line 16 in Shared/LeaderboardData.cs

View workflow job for this annotation

GitHub Actions / deploy

Non-nullable property 'Player' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
public int Count { get; set; }
}
27 changes: 27 additions & 0 deletions Shared/PlayerData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Shared;

/// <summary>
/// Represents collected player data from replays. This is used to generate leaderboards and other statistics.
/// </summary>
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();
}
}
Loading

0 comments on commit 3f19fd7

Please sign in to comment.