using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Leaderboards; using System.Text.Json; namespace RadicadeServer.Leaderboard; public static class LeaderboardStore { private const int FirstGlobalStatChannel = 1000; public static async Task SubmitScoreAsync(IDbContextFactory dbFactory, string? rawBody, long authPlayerId, long queryPlayerId) { var parsed = ParseStatSubmission(rawBody); if (parsed == null) return null; long playerId = parsed.Value.PlayerIdFromBody > 0 ? parsed.Value.PlayerIdFromBody : authPlayerId > 0 ? authPlayerId : queryPlayerId; if (playerId <= 0) { return new { Success = false, Reason = "No player identity" }; } await using var db = await dbFactory.CreateDbContextAsync(); var now = DateTime.UtcNow; var added = 0; foreach (var (channel, score, time) in parsed.Value.Stats) { if (score == 0 && time == 0) continue; db.LeaderboardScores.Add(new LeaderboardScore { RoomId = parsed.Value.RoomId, SubRoomId = parsed.Value.SubRoomId, PlayerId = playerId, StatChannel = channel, Score = score, Time = time, SubmittedAt = now }); added++; } if (added == 0) return new { Success = false, Reason = "Nothing to record" }; await db.SaveChangesAsync(); var last = parsed.Value.Stats[^1]; var board = await LoadBoardAsync(db, parsed.Value.RoomId, last.Channel); int rank = RankBoard(board, false).FindIndex(e => e.PlayerId == playerId) + 1; int total = board.GroupBy(x => x.PlayerId).Count(); return new { Success = true, PlayerId = playerId, RoomId = parsed.Value.RoomId, StatChannel = last.Channel, StatValue = last.Score, Rank = rank, Total = total }; } public static async Task GetRanksAsync(IDbContextFactory dbFactory, GetRanksRequest req) { await using var db = await dbFactory.CreateDbContextAsync(); var board = await LoadBoardAsync(db, req.RoomId, req.StatChannel); var ranked = RankBoard(board, req.SortAscending); var names = await LoadPlayerNamesAsync(db, BoardPlayerIds(ranked, req.PlayerId)); int total = ranked.Count; int start = req.RankStart > 0 ? req.RankStart : 1; int end = req.RankEnd >= req.RankStart ? req.RankEnd : Math.Min(total, 100); var slice = ranked .Skip(start - 1) .Take(Math.Max(0, end - start + 1)) .Select((e, i) => new LeaderboardEntryResponse { PlayerId = e.PlayerId, PlayerName = names.TryGetValue(e.PlayerId, out var n) ? n : "", Score = e.BestValue, Time = e.BestTime, Rank = start + i }) .ToList(); int myRank = 0; if (req.PlayerId > 0) { var me = ranked.FirstOrDefault(e => e.PlayerId == req.PlayerId); if (me != null) myRank = ranked.IndexOf(me) + 1; } if (total == 0 && req.PlayerId > 0) { slice.Add(CreatePlaceholderEntry(req.PlayerId, names)); total = 1; myRank = 1; } return new LeaderboardResponse { Rows = slice, Total = total, MyRank = myRank }; } public static async Task GetPlayerRankAsync(IDbContextFactory dbFactory, GetRankRequest req) { await using var db = await dbFactory.CreateDbContextAsync(); var board = await LoadBoardAsync(db, req.RoomId, req.StatChannel); var ranked = RankBoard(board, req.SortAscending); var names = await LoadPlayerNamesAsync(db, BoardPlayerIds(ranked, req.PlayerId)); int total = ranked.Count; var entry = ranked.FirstOrDefault(e => e.PlayerId == req.PlayerId); int rank = entry == null ? 0 : ranked.IndexOf(entry) + 1; double score = entry?.BestValue ?? 0; long time = entry?.BestTime ?? 0; string name = names.TryGetValue(req.PlayerId, out var n) ? n : ""; if (entry == null && req.PlayerId > 0 && total == 0) { rank = 1; total = 1; } var row = new LeaderboardEntryResponse { PlayerId = req.PlayerId, PlayerName = name, Score = score, Time = time, Rank = rank }; return new { row.PlayerId, row.PlayerName, row.Score, row.Time, row.Rank, Total = total, Rows = new List { row } }; } public static async Task GetNearbyScoresAsync(IDbContextFactory dbFactory, GetNearbyScoresRequest req) { await using var db = await dbFactory.CreateDbContextAsync(); var window = req.WindowSize > 0 ? req.WindowSize : 10; var board = await LoadBoardAsync(db, req.RoomId, req.StatChannel); var ranked = RankBoard(board, req.SortAscending); var names = await LoadPlayerNamesAsync(db, BoardPlayerIds(ranked, req.PlayerId)); int total = ranked.Count; int myRank = 0; if (req.PlayerId > 0) { var me = ranked.FirstOrDefault(e => e.PlayerId == req.PlayerId); if (me != null) myRank = ranked.IndexOf(me) + 1; } int start = myRank > 0 ? Math.Max(1, myRank - window / 2) : 1; var slice = ranked .Skip(start - 1) .Take(window) .Select((e, i) => new LeaderboardEntryResponse { PlayerId = e.PlayerId, PlayerName = names.TryGetValue(e.PlayerId, out var n) ? n : "", Score = e.BestValue, Time = e.BestTime, Rank = start + i }) .ToList(); if (req.PlayerId > 0 && myRank > 0 && slice.All(e => e.PlayerId != req.PlayerId)) { var me = ranked.First(e => e.PlayerId == req.PlayerId); slice.Add(new LeaderboardEntryResponse { PlayerId = me.PlayerId, PlayerName = names.TryGetValue(me.PlayerId, out var n) ? n : "", Score = me.BestValue, Time = me.BestTime, Rank = myRank }); } if (total == 0 && req.PlayerId > 0) { slice.Add(CreatePlaceholderEntry(req.PlayerId, names)); total = 1; myRank = 1; } return new LeaderboardResponse { Rows = slice, Total = total, MyRank = myRank }; } private static (long RoomId, long SubRoomId, long PlayerIdFromBody, List<(int Channel, double Score, long Time)> Stats)? ParseStatSubmission(string? rawBody) { if (string.IsNullOrWhiteSpace(rawBody)) return null; JsonElement root; try { using var doc = JsonDocument.Parse(rawBody); root = doc.RootElement.Clone(); } catch { return null; } long roomId = 0, subRoomId = 0, playerId = 0; var stats = new List<(int Channel, double Score, long Time)>(); if (root.ValueKind == JsonValueKind.Object) { roomId = GetLong(root, "RoomId", "roomId", "Room"); subRoomId = GetLong(root, "SubRoomId", "subRoomId", "SubRoom"); playerId = GetLong(root, "PlayerId", "playerId", "Player"); var nested = GetArray(root, "Stats", "stats", "Scores", "scores"); if (nested != null) { foreach (var item in nested) AddStat(stats, item); } else { AddStat(stats, root); } } else if (root.ValueKind == JsonValueKind.Array) { foreach (var item in root.EnumerateArray()) AddStat(stats, item); } if (stats.Count == 0) return null; return (roomId, subRoomId, playerId, stats); } private static void AddStat(List<(int Channel, double Score, long Time)> stats, JsonElement el) { var channel = GetInt(el, "StatChannel", "statChannel", "Channel", "BoardNumber", "Board", "StatId", "statId"); var score = GetDouble(el, "Score", "score", "StatValue", "statValue", "Value", "value", "Wins", "wins", "Count", "count"); var time = GetLong(el, "Time", "time", "Milliseconds", "TimeMs"); if (score != 0 || time != 0) stats.Add((channel, score, time)); } internal static List? GetArray(JsonElement el, params string[] names) { foreach (var n in names) { if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.Array) return p.EnumerateArray().ToList(); } return null; } internal static int GetInt(JsonElement el, params string[] names) { foreach (var n in names) if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var v)) return v; return 0; } internal static double GetDouble(JsonElement el, params string[] names) { foreach (var n in names) if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetDouble(out var v)) return v; return 0; } internal static long GetLong(JsonElement el, params string[] names) { foreach (var n in names) if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var v)) return v; return 0; } internal static bool GetBool(JsonElement el, params string[] names) { foreach (var n in names) if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.True) return true; return false; } private static async Task> LoadBoardAsync(RecDBContext db, long roomId, int statChannel) { var q = db.LeaderboardScores.AsNoTracking(); bool global = statChannel >= FirstGlobalStatChannel || roomId <= 0; q = global ? q.Where(x => x.StatChannel == statChannel) : q.Where(x => x.RoomId == roomId && x.StatChannel == statChannel); return await q.ToListAsync(); } private static List RankBoard(List scores, bool sortAscending) { var best = scores .GroupBy(x => x.PlayerId) .Select(g => new { PlayerId = g.Key, BestValue = sortAscending ? g.Min(x => x.Score != 0 ? x.Score : x.Time) : g.Max(x => x.Score != 0 ? x.Score : x.Time), BestTime = sortAscending ? g.Min(x => x.Time) : g.Max(x => x.Time) }) .ToList(); var ordered = sortAscending ? best.OrderBy(x => x.BestValue).ThenBy(x => x.BestTime).ToList() : best.OrderByDescending(x => x.BestValue).ThenBy(x => x.BestTime).ToList(); return ordered.Select(e => new RankedEntry(e.PlayerId, e.BestValue, e.BestTime)).ToList(); } private static List BoardPlayerIds(List ranked, int askingPlayerId) { var ids = ranked.Select(e => e.PlayerId).ToList(); if (askingPlayerId > 0 && !ids.Contains(askingPlayerId)) ids.Add(askingPlayerId); return ids; } private static async Task> LoadPlayerNamesAsync(RecDBContext db, List playerIds) { if (playerIds.Count == 0) return new Dictionary(); return await db.Profiles.AsNoTracking() .Where(p => playerIds.Contains(p.accountId)) .ToDictionaryAsync(p => p.accountId, p => p.displayName ?? p.username); } private static LeaderboardEntryResponse CreatePlaceholderEntry(long playerId, Dictionary names) { return new LeaderboardEntryResponse { PlayerId = playerId, PlayerName = names.TryGetValue(playerId, out var name) ? name : "", Score = 0, Time = 0, Rank = 1 }; } private sealed record RankedEntry(long PlayerId, double BestValue, long BestTime); } public abstract class LeaderboardControllerBase : Controller { protected readonly IDbContextFactory DbFactory; protected LeaderboardControllerBase(IDbContextFactory dbFactory) { DbFactory = dbFactory; } private async Task ReadBodyAsync() { using var reader = new StreamReader(Request.Body); return await reader.ReadToEndAsync(); } private long QueryPlayerId() { if (long.TryParse(Request.Query["playerId"], out var id) && id > 0) return id; if (long.TryParse(Request.Query["PlayerId"], out id) && id > 0) return id; return 0; } private void FillPlayerIdFromRequest(GetRankRequest req) { if (req.PlayerId > 0) return; var queryPlayerId = QueryPlayerId(); if (queryPlayerId > 0 && queryPlayerId <= int.MaxValue) { req.PlayerId = (int)queryPlayerId; return; } var authPlayerId = User.GetUserId(); if (authPlayerId > 0 && authPlayerId <= int.MaxValue) req.PlayerId = (int)authPlayerId.Value; } private async Task ParseRanksRequestAsync() { var raw = await ReadBodyAsync(); var req = new GetRanksRequest(); if (!string.IsNullOrWhiteSpace(raw)) { try { using var doc = JsonDocument.Parse(raw); var el = doc.RootElement; req.PlayerId = LeaderboardStore.GetInt(el, "PlayerId", "playerId", "Player"); req.StatChannel = LeaderboardStore.GetInt(el, "StatChannel", "statChannel", "Channel", "StatId", "statId"); req.RoomId = LeaderboardStore.GetLong(el, "RoomId", "roomId", "Room"); req.FilterType = LeaderboardStore.GetInt(el, "FilterType", "filterType"); req.Timeframe = LeaderboardStore.GetInt(el, "Timeframe", "timeframe"); req.SortAscending = LeaderboardStore.GetBool(el, "SortAscending", "sortAscending"); req.RankStart = LeaderboardStore.GetInt(el, "RankStart", "rankStart"); req.RankEnd = LeaderboardStore.GetInt(el, "RankEnd", "rankEnd"); } catch { } } return req; } private async Task ParseNearbyScoresRequestAsync() { var raw = await ReadBodyAsync(); var req = new GetNearbyScoresRequest(); if (!string.IsNullOrWhiteSpace(raw)) { try { using var doc = JsonDocument.Parse(raw); var el = doc.RootElement; req.PlayerId = LeaderboardStore.GetInt(el, "PlayerId", "playerId", "Player"); req.StatChannel = LeaderboardStore.GetInt(el, "StatChannel", "statChannel", "Channel", "StatId", "statId"); req.RoomId = LeaderboardStore.GetLong(el, "RoomId", "roomId", "Room"); req.FilterType = LeaderboardStore.GetInt(el, "FilterType", "filterType"); req.Timeframe = LeaderboardStore.GetInt(el, "Timeframe", "timeframe"); req.SortAscending = LeaderboardStore.GetBool(el, "SortAscending", "sortAscending"); req.WindowSize = LeaderboardStore.GetInt(el, "WindowSize", "windowSize"); } catch { } } return req; } [HttpPost("CheckAndSetStat")] public async Task CheckAndSetStat() { var rawBody = await ReadBodyAsync(); var result = await LeaderboardStore.SubmitScoreAsync(DbFactory, rawBody, User.GetUserId() ?? 0, QueryPlayerId()); return result == null ? BadRequest(new { Success = false }) : Ok(result); } [HttpPost("SubmitScore")] public async Task SubmitScore() { var rawBody = await ReadBodyAsync(); var result = await LeaderboardStore.SubmitScoreAsync(DbFactory, rawBody, User.GetUserId() ?? 0, QueryPlayerId()); return result == null ? BadRequest(new { Success = false }) : Ok(result); } [HttpPost("GetRanks")] public async Task GetRanks() { var req = await ParseRanksRequestAsync(); FillPlayerIdFromRequest(req); return Ok(await LeaderboardStore.GetRanksAsync(DbFactory, req)); } [HttpGet("GetRanks")] public async Task GetRanksGet([FromQuery] GetRanksRequest req) { if (req == null) return BadRequest(); FillPlayerIdFromRequest(req); return Ok(await LeaderboardStore.GetRanksAsync(DbFactory, req)); } [HttpPost("GetPlayerRank")] public async Task GetPlayerRank() { var req = await ParseRanksRequestAsync(); FillPlayerIdFromRequest(req); return Ok(await LeaderboardStore.GetPlayerRankAsync(DbFactory, req)); } [HttpGet("GetPlayerRank")] public async Task GetPlayerRankGet([FromQuery] GetRankRequest req) { if (req == null) return BadRequest(); FillPlayerIdFromRequest(req); return Ok(await LeaderboardStore.GetPlayerRankAsync(DbFactory, req)); } [HttpPost("GetNearbyScores")] public async Task GetNearbyScores() { var req = await ParseNearbyScoresRequestAsync(); FillPlayerIdFromRequest(req); return Ok(await LeaderboardStore.GetNearbyScoresAsync(DbFactory, req)); } [HttpGet("GetNearbyScores")] public async Task GetNearbyScoresGet([FromQuery] GetNearbyScoresRequest req) { if (req == null) return BadRequest(); FillPlayerIdFromRequest(req); return Ok(await LeaderboardStore.GetNearbyScoresAsync(DbFactory, req)); } internal static string Truncate(string? s) { if (string.IsNullOrWhiteSpace(s)) return ""; return s.Length <= 500 ? s : s.Substring(0, 500) + "..."; } } [Route("leaderboard/leaderboard")] public class LeaderboardAPI : LeaderboardControllerBase { public LeaderboardAPI(IDbContextFactory dbFactory) : base(dbFactory) { } } [Route("leaderboard")] public class LeaderboardCompatAPI : LeaderboardControllerBase { public LeaderboardCompatAPI(IDbContextFactory dbFactory) : base(dbFactory) { } } [Route("leaderboard/{**path}")] public class LeaderboardCatchallController : Controller { [HttpGet] [HttpPost] [HttpPut] [HttpDelete] [HttpPatch] public async Task Catch(string? path) { string body = ""; try { using var reader = new StreamReader(Request.Body); body = await reader.ReadToEndAsync(); } catch { } return NotFound(); } }