mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
634 lines
16 KiB
C#
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();
|
|
}
|
|
}
|