Files
2026-10-16 23:33:13 -07:00

634 lines
16 KiB
C#

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<object?> SubmitScoreAsync(IDbContextFactory<RecDBContext> 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<LeaderboardResponse> GetRanksAsync(IDbContextFactory<RecDBContext> 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<object> GetPlayerRankAsync(IDbContextFactory<RecDBContext> 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<LeaderboardEntryResponse> { row }
};
}
public static async Task<LeaderboardResponse> GetNearbyScoresAsync(IDbContextFactory<RecDBContext> 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<JsonElement>? 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<List<LeaderboardScore>> 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<RankedEntry> RankBoard(List<LeaderboardScore> 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<long> BoardPlayerIds(List<RankedEntry> 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<Dictionary<long, string>> LoadPlayerNamesAsync(RecDBContext db, List<long> playerIds)
{
if (playerIds.Count == 0) return new Dictionary<long, string>();
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<long, string> 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<RecDBContext> DbFactory;
protected LeaderboardControllerBase(IDbContextFactory<RecDBContext> dbFactory)
{
DbFactory = dbFactory;
}
private async Task<string?> 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<GetRanksRequest> 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<GetNearbyScoresRequest> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetRanks()
{
var req = await ParseRanksRequestAsync();
FillPlayerIdFromRequest(req);
return Ok(await LeaderboardStore.GetRanksAsync(DbFactory, req));
}
[HttpGet("GetRanks")]
public async Task<IActionResult> GetRanksGet([FromQuery] GetRanksRequest req)
{
if (req == null) return BadRequest();
FillPlayerIdFromRequest(req);
return Ok(await LeaderboardStore.GetRanksAsync(DbFactory, req));
}
[HttpPost("GetPlayerRank")]
public async Task<IActionResult> GetPlayerRank()
{
var req = await ParseRanksRequestAsync();
FillPlayerIdFromRequest(req);
return Ok(await LeaderboardStore.GetPlayerRankAsync(DbFactory, req));
}
[HttpGet("GetPlayerRank")]
public async Task<IActionResult> GetPlayerRankGet([FromQuery] GetRankRequest req)
{
if (req == null) return BadRequest();
FillPlayerIdFromRequest(req);
return Ok(await LeaderboardStore.GetPlayerRankAsync(DbFactory, req));
}
[HttpPost("GetNearbyScores")]
public async Task<IActionResult> GetNearbyScores()
{
var req = await ParseNearbyScoresRequestAsync();
FillPlayerIdFromRequest(req);
return Ok(await LeaderboardStore.GetNearbyScoresAsync(DbFactory, req));
}
[HttpGet("GetNearbyScores")]
public async Task<IActionResult> 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<RecDBContext> dbFactory) : base(dbFactory) { }
}
[Route("leaderboard")]
public class LeaderboardCompatAPI : LeaderboardControllerBase
{
public LeaderboardCompatAPI(IDbContextFactory<RecDBContext> dbFactory) : base(dbFactory) { }
}
[Route("leaderboard/{**path}")]
public class LeaderboardCatchallController : Controller
{
[HttpGet]
[HttpPost]
[HttpPut]
[HttpDelete]
[HttpPatch]
public async Task<IActionResult> Catch(string? path)
{
string body = "";
try
{
using var reader = new StreamReader(Request.Body);
body = await reader.ReadToEndAsync();
}
catch { }
return NotFound();
}
}